Skip to content
Knowledge Hub

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

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?

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

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 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.

The demo registers email, loyalty and analytics listeners on a Node EventEmitter, then makes loyalty throw:

EventEmitter runs listeners inside emit(): loyalty throws, analytics is skipped, and OrderService gets the exception after the order is already paid
EventEmitter runs listeners inside emit(): loyalty throws, analytics is skipped, and OrderService gets the exception after the order is already paidStep 1/6: OrderService commits order-101 as paid. From here the payment is a fact.timeCOMMITorder paidOrderServicemarkPaid()Emaillistener, 1stLoyaltylistener, 2ndAnalyticslistener, 3rd
Step 1/6

OrderService commits order-101 as paid. From here the payment is a fact.

State after each step:

  1. COMMIT. Order 101 is paid in the database. Nothing else has happened.
  2. emit(). OrderService is now blocked inside emit(); listeners run on its call stack.
  3. Email. Runs first because it registered first, returns.
  4. Loyalty throws. The loyalty DB is down.
  5. Analytics skipped. EventEmitter does not catch listener errors, so later listeners never run.
  6. 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() returns true if there were listeners.
  • A listener that throws stops the loop and the error propagates out of emit().
  • async listeners 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 ran
2) A listener throws synchronously
email sent
emit() threw "loyalty DB down"; analytics never ran
3) An async listener rejects
plain emitter: emit() returned normally
plain emitter: unhandledRejection -> ["SMTP timeout"]
captureRejections: routed to 'error' -> SMTP timeout
4) Isolating publisher: one failure does not stop the others
email=ok loyalty=failed(loyalty DB down) analytics=ok
5) 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 replay

The demo installs a process.on('unhandledRejection') handler to print part 3; without it, Node 24 exits.

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 committed
this.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.

  • 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 with Promise.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.

Terminal window
npx --prefix examples ts-node examples/src/observer-domain-events/order-paid-demo.ts

When events help

  • OrderService does 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.
  1. Classify each reaction: needs a result, same transaction, best-effort, or must-not-lose.
  2. Emit only after the transaction commits; must-not-lose reactions go through an outbox.
  3. Every listener catches and logs its own errors with the event id; no listener can fail the publisher.
  4. Never leave an async listener without error handling; use captureRejections plus an 'error' listener or the NestJS default suppressErrors.
  5. Do not rely on listener order; one listener per independent reaction.
  6. Events carry ids and values, not request objects or ORM entities.
  7. Consumers of integration events deduplicate by event id.

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 only

Why

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
  1. In the swimlane, why is the 500 response worse than a failed payment?
  2. With captureRejections: true and no 'error' listener, what happens when the email listener rejects?
  3. Which of email, loyalty and analytics would you keep in-process, and why?