Observer and domain events: what OrderPaid guarantees when it fires in-process, and when you need an outbox Intermediate
Estimated reading time: 9 min read
The problem
Section titled “The problem”When the payment provider confirms order 101 (250,000 VND), OrderService.markPaid() commits the order. Three teams want to react: send a receipt email, add 250 loyalty points, and track a purchase analytics event.
Calling the three services from markPaid() couples the order flow to every team. Adding a fourth reaction means editing and retesting order code. Observer flips the dependency: OrderService announces OrderPaid, and each team registers its own listener.
That decoupling raises the questions this article answers: when emit() returns, what has run? What if a listener throws? And what happens to the email if the process dies right after the commit?
What to choose? (summary first)
Section titled “What to choose? (summary first)”| Situation | Choose | Condition |
|---|---|---|
| The caller needs the result to respond | A direct call, not an event | Events do not return business results |
| Best-effort reaction in the same process (analytics, cache invalidation) | In-process event (EventEmitter, @nestjs/event-emitter) |
Losing it on a crash is acceptable; each listener catches its own errors |
| Reaction must commit or roll back with the order (points in the same DB) | Domain event dispatched inside the transaction, or a direct call | Same database, same transaction |
| Reaction must not be lost (receipt email, another service) | Integration event via transactional outbox | Relay + broker + idempotent consumer |
Core concepts
Section titled “Core concepts”Observer has two roles: a subject that keeps a list of subscribers and notifies them, and observers that react. With an event bus in between, OrderService publishes and listeners subscribe; neither imports the other.
An event is a fact in the past tense: OrderPaid, not SendEmail. It is immutable and carries what listeners need (orderId, customerId, amountMinor). A command asks one handler to do something; an event may have zero or many listeners.
Domain events vs integration events
Section titled “Domain events vs integration events”| Domain event | Integration event | |
|---|---|---|
| Audience | Same bounded context, same process | Other services, other bounded contexts |
| Transport | In-memory dispatcher | Broker (Kafka, RabbitMQ), outbox table |
| Timing | Sync or async, dispatched before or after commit | Always async, only after the data is committed |
| Survives a crash | No | Yes, if written in the same transaction as the data |
| Schema | Internal, can change with the code | A public contract, versioned |
Microsoft’s guidance treats them as the same idea with different implementations: a domain event handler often creates an integration event, which must only go out if the entity was persisted.
How it works
Section titled “How it works”The demo registers email, loyalty and analytics listeners on a Node EventEmitter, then makes loyalty throw:
OrderService commits order-101 as paid. From here the payment is a fact.
State after each step:
- COMMIT. Order 101 is paid in the database. Nothing else has happened.
- emit().
OrderServiceis now blocked insideemit(); listeners run on its call stack. - Email. Runs first because it registered first, returns.
- Loyalty throws. The loyalty DB is down.
- Analytics skipped.
EventEmitterdoes not catch listener errors, so later listeners never run. - Exception escapes.
markPaid()throws after the commit. The client sees a 500 for a paid order and may pay again.
Node EventEmitter rules (Node 24, verified in the demo)
Section titled “Node EventEmitter rules (Node 24, verified in the demo)”- Listeners run synchronously, in registration order;
emit()returnstrueif there were listeners. - A listener that throws stops the loop and the error propagates out of
emit(). asynclisteners are started but not awaited. A rejection becomes an unhandled rejection; on Node 24’s default settings that ends the process with exit code 1.new EventEmitter({ captureRejections: true })routes rejections to the'error'event. An'error'event with no listener throws and also crashes the process.- More than 10 listeners per event prints a “possible memory leak” warning, not an error.
1) Listeners run synchronously, in registration order email order-101 loyalty +250 points analytics tracked emit() returned true after all three ran2) A listener throws synchronously email sent emit() threw "loyalty DB down"; analytics never ran3) An async listener rejects plain emitter: emit() returned normally plain emitter: unhandledRejection -> ["SMTP timeout"] captureRejections: routed to 'error' -> SMTP timeout4) Isolating publisher: one failure does not stop the others email=ok loyalty=failed(loyalty DB down) analytics=ok5) Process exits before the deferred email handler runs order committed, event emitted, email scheduled in memory process exits (deploy, OOM, crash): the email is gone, nothing to replayThe demo installs a process.on('unhandledRejection') handler to print part 3; without it, Node 24 exits.
NestJS: @nestjs/event-emitter
Section titled “NestJS: @nestjs/event-emitter”The package wraps eventemitter2. Listeners are provider methods:
@Injectable()export class LoyaltyListener { @OnEvent('order.paid') async addPoints(event: OrderPaid) { await this.points.add(event.customerId, event.amountMinor / 1000); }}
// in OrderService, after the transaction has committedthis.events.emit('order.paid', new OrderPaid('order-101', 'c-7', 250_000));Behavior checked by running @nestjs/event-emitter 12.0.1 (eventemitter2 6.4.9) on Node 24:
| Behavior | Result |
|---|---|
| Listener throws, default options | Error is logged (suppressErrors defaults to true); other listeners still run |
emit() with async listeners |
Returns before their awaits finish |
@OnEvent(..., { async: true }) |
Listener starts after emit() returns |
suppressErrors: false with emit() |
Unhandled rejection (process exit by default) |
suppressErrors: false with emitAsync() |
The returned promise rejects with the first error; slower listeners keep running |
The NestJS docs add one trap: events emitted before onApplicationBootstrap (constructors, onModuleInit) may be missed because listeners are not registered yet. Await EventEmitterReadinessWatcher.waitUntilReady() first.
Operational details
Section titled “Operational details”- Ordering is registration order, which in NestJS depends on module and provider order. Do not let listener B rely on listener A having run; if it must, it is one handler, not two.
- Isolation: wrap each listener (or the publisher, as
publishIsolated()in the demo does withPromise.allSettled) so one failure is logged with the event id and does not affect others. - Durability: in-process events live in memory. A deploy, OOM kill or crash between commit and listener completion loses them (demo part 5), with nothing to replay.
- Emit after commit: emitting inside the transaction lets listeners act on data that may still roll back.
- Request context: async listeners outlive the request; pass ids in the event instead of reading request-scoped state. See AsyncLocalStorage.
For a reaction that must happen, write the integration event to an outbox table in the same transaction as the order:
Loading diagram…
The relay publishes at least once, so consumers deduplicate by event id. Details in Transactional outbox.
Real-world example
Section titled “Real-world example”npx --prefix examples ts-node examples/src/observer-domain-events/order-paid-demo.tsTrade-offs
Section titled “Trade-offs”When events help
OrderServicedoes not change when a team adds a reaction.- Each listener is tested alone with a sample event.
- The event name documents a business fact others can rely on.
Costs
- Control flow is implicit: reading
markPaid()does not show what happens next. - In-process events give no durability, retry or ordering guarantee across restarts.
- Error handling must be explicit per listener, or one failure breaks the caller.
- Integration events need an outbox, a broker, idempotent consumers and schema versioning.
Production checklist
Section titled “Production checklist”- Classify each reaction: needs a result, same transaction, best-effort, or must-not-lose.
- Emit only after the transaction commits; must-not-lose reactions go through an outbox.
- Every listener catches and logs its own errors with the event id; no listener can fail the publisher.
- Never leave an
asynclistener without error handling; usecaptureRejectionsplus an'error'listener or the NestJS defaultsuppressErrors. - Do not rely on listener order; one listener per independent reaction.
- Events carry ids and values, not request objects or ORM entities.
- Consumers of integration events deduplicate by event id.
Common mistakes
Section titled “Common mistakes”The most common bug is the swimlane above: the publisher sees the listener’s exception.
Emitting inside the transaction
Common
await db.transaction(async (tx) => { await tx.orders.markPaid(orderId); this.events.emit('order.paid', event); // email goes out even if COMMIT fails});Prefer
await db.transaction(async (tx) => { await tx.orders.markPaid(orderId); await tx.outbox.insert(event); // committed together});this.events.emit('order.paid', event); // best-effort listeners onlyWhy
A listener may act on an order that later rolls back. Emit after commit; durable reactions read the outbox.
The second trap only shows up in production:
| Misconception | Consequence | What to do |
|---|---|---|
“emit() is asynchronous” |
Slow listeners add latency to the request | Listeners run in the caller’s stack; defer heavy work |
| “Events are fire-and-forget, so they are safe” | Lost email after a crash | Outbox for must-not-lose reactions |
| “Listener order is stable, B can use A’s result” | Breaks when modules are reordered | Merge into one handler or chain events |
| “Domain event = integration event” | Internal schema leaks to other services | Translate to a versioned integration event |
Self-check
Section titled “Self-check”- In the swimlane, why is the 500 response worse than a failed payment?
- With
captureRejections: trueand no'error'listener, what happens when the email listener rejects? - Which of email, loyalty and analytics would you keep in-process, and why?
Summary
Section titled “Summary”References
Section titled “References”- Events — Node.js v24: synchronous listener order,
'error'events,captureRejections,defaultMaxListeners. - Events — NestJS:
@OnEvent,suppressErrors, wildcards, the readiness watcher. - Domain events: design and implementation — Microsoft Learn: domain versus integration events, dispatching before or after commit.
- Observer — Refactoring.Guru: subject and subscriber roles.