The style guide is mostly based on author experience in using Marble.js on large production-ready projects following microservice/event-driven architecture style.
Project organization
Marble.js framework doesn't define any strict file structures and conventions from the organization-level perspective that each developer should enforce. Below you can find some useful hints that you can follow when organizing your Marble.js app.
Keep server and connected listeners in separate files
From the framework perspective, listeners and servers are separate layers that are responsible for different things. #createServer and similar factory functions are responsible for handling transport-layer-related processes, like: bootstrapping server and bounded context, listening for incoming messages or reacting for transport-layer events, where for the contrast, listeners are responsible for processing I/O messages that go through underlying transport layer in order to fulfill business needs. Basically, it is just about the single responsibility principle.
By convention Marble.js follows suffixed file naming which results in:
Server
Listener
http.server.ts
http.listener.ts
microservice.server.ts
microservice.listener.ts
websocket.server.ts
websocket.listener.ts
NONE
eventbus.listener.ts
Keep HTTP route with its corresponding HttpEffect
In Marble.js HTTP effects are tightly connected to the route on which they operate. Having them in a separation makes the code more less readable and understandable. Always try to keep them as a one unit. Every HttpEffect that comes through r.pipe route builder is accessible via .effect property of the returned RouteEffect object. You can access it via this way if you want to unit test only the effect function.
❌ Bad
import { r } from'@marblejs/core';import { getFooEffect } from'./getFoo.effect';constgetFoo=r.pipe(r.matchPath('/foo'),r.matchType('GET'),r.useEffect(getFooEffect));
Use PascalCase naming convention with Token suffix for token definitions.
Always remember to define a context token name identifier. It will help you quickly recognize what dependency is missing when asking for it via useContext hook function.
Place token next to reader definition. It is easier to navigate to reader implementation via popular "Go to Implementation" mechanism.
Always try to inject bound dependencies at the top level of your effects (before returned Observable stream). All effects are evaluated eagerly, so in case of missing context dependency the framework will be able to spot issues during initial bootstrap.
❌ Bad
constpostUser$=r.pipe(r.matchPath('/'),r.matchType('POST'),r.useEffect((req$, ask) =>req$.pipe( validateRequest,mergeMap(req => {constuserRepository=useContext(UserRepositoryToken)(ask);const { body } = req;return userRepository.persist(body).pipe(mergeMap(userRepository.getById),map(user => ({ body: user })), ); }), ));
Always try match events by event I/O codec - avoid raw literals since they don't carry the actual event payload type
Event-based communication follows the same laws as request-based communication - each incoming event should be validated before usage (eg. using previously mentioned event codec).
❌ Bad
import { act, matchEvent } from'@marblejs/core';import { MsgEffect } from'@marblejs/messaging';exportconstuserCreated$:MsgEffect= event$ =>event$.pipe(// event payload is unknown, no type is inferred... matchEvent('USER_CREATED'),act(event =>...), );
❌ / ✅ Better
import { act, matchEvent } from'@marblejs/core';import { MsgEffect } from'@marblejs/messaging';import { UserCreatedEvent } from'./user.event.ts';exportconstuserCreated$:MsgEffect= event$ =>event$.pipe(// type is inferred but event still requires validation...matchEvent(UserCreatedEvent),act(event =>...), );
Each messaging effect should handle errors in a disposable streams either via mergeMap/switchMap/... operators with combination of catchError or viaact operator.