@roostjs/events
The observer pattern in Roost, how in-process event dispatching differs from message queues, listener vs subscriber design, and the ShouldQueue escape hatch.
The Observer Pattern in Workers
@roostjs/events implements the observer pattern: an event is dispatched, and
any number of listeners react to it. This separates the code that produces a
domain action (user registered, order placed) from the code that responds to it
(send email, update analytics, provision trial subscription).
Without events, the handler that creates a user record knows about email sending, analytics, and provisioning. With events, it only knows about user creation. The listeners — which may live in different modules — subscribe to the event and handle their own concerns. Adding a new side effect does not require modifying the original handler.
In-Process vs Message Queue
EventDispatcher.dispatch() calls listeners synchronously within the same Worker
invocation. All listeners for a given event execute in parallel via Promise.all
before dispatch() resolves. This is straightforward but means listener failures
propagate back to the dispatch caller, and listener execution time adds to the
request's total response time.
For listeners that are slow or whose success is not required for the response,
implement ShouldQueue. The dispatcher checks for this marker and dispatches
the listener as a Job via @roostjs/queue instead of calling handle() inline.
The job runs asynchronously in a separate Worker invocation, and the original
request completes immediately.
Choose in-process listeners for fast, critical side effects that the caller needs
to know about. Choose ShouldQueue listeners for slow, non-critical work like
sending emails, generating reports, or updating external systems.
Broadcast Interop
Events that implement the BroadcastableEvent interface from @roostjs/broadcast
are automatically forwarded to BroadcastManager after dispatching to listeners.
This means a single UserRegistered.dispatch(event) call can simultaneously
notify in-process listeners and push an update to connected WebSocket clients —
no extra code needed at the dispatch site.
The interop is opt-in and lazy: the dispatcher attempts to import
@roostjs/broadcast at dispatch time. If the package is not installed or
BroadcastManager is not initialized, the dispatch succeeds and a warning is
logged. This prevents a hard dependency between the two packages.
Listener vs Subscriber
Listener and Subscriber both receive events; they are registered differently
and serve different organizational purposes.
A Listener class handles exactly one event type. Use listeners when the
handler has a single, focused responsibility: SendWelcomeEmailListener handles
UserRegistered and nothing else.
A Subscriber class groups handlers for multiple events in one class. Use
subscribers when the handlers share domain context: a NotificationSubscriber
that reacts to UserRegistered, OrderPlaced, and PasswordReset can share
notification service logic without injecting it into three separate listener classes.
Both are registered in EventServiceProvider. Internally, the service provider
wraps subscriber methods in adapter objects that satisfy the ListenerClass
interface, so the dispatcher treats them identically.
Testing Model
Event.fake() uses a WeakMap to store fake state per event class rather than
a global. This means multiple event classes can be faked independently in the
same test without interfering with each other. Calling UserRegistered.fake()
does not affect OrderPlaced, and vice versa.
assertDispatched() accepts an optional predicate to make assertions about
specific dispatch arguments:
UserRegistered.assertDispatched((event) => event.email === 'alice@example.com');This is more precise than just asserting the event was dispatched — it verifies
the event was dispatched with the right data. For tests that need to verify no
side effects happened, assertNotDispatched() reads clearly at the test assertion
level.