Domain Events & The Mediator Pattern: Decoupled Cross-Aggregate Workflows
When an Order is placed, the Inventory, Billing, and Notification services must react — but they must not be directly coupled to the Order Aggregate. Domain Events and the Mediator pattern decouple these cross-aggregate side effects through an in-process event bus, enabling rich domain workflows without transaction scope violations.
Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. And when an Order is placed, three things must happen: inventory is reserved, payment is captured, and a confirmation email is sent. The temptation is to call all three services directly from inside place(). This is the exact coupling that Domain Events exist to prevent. An Order knows that something significant happened. It does not know — and must not know — who cares or what they will do about it.
This article implements the full Domain Event machinery for the billing engine: the event collection protocol inside AggregateRoot, the IEventBus port, concrete event handlers for inventory, payment, and email, and the precise timing rule for when events are dispatched relative to the database transaction.
Architectural Note
Prerequisites: Part 4 (Aggregates & Repositories) — specifically the AggregateRoot.pullDomainEvents() mechanism, which is the collection side of the pattern implemented here. The dispatch side (the IEventBus) is introduced in this article.
This is the implementation most engineers write first, and it ships to production in most Node.js services:
TYPESCRIPT
// ❌ Anti-Pattern: Order.place() directly orchestrates cross-cutting side effectsexportclassOrderextendsAggregateRoot<OrderId> {
constructor(// ❌ The Order domain object now depends on application-layer servicesprivatereadonlyinventoryService: InventoryService,
privatereadonlypaymentService: PaymentService,
privatereadonlyemailService: EmailService,
) { /* ... */ }
asyncplace(): Promise<void> {
if (this._status !== OrderStatus.PENDING) thrownewDomainError('...');
if (this._items.length === 0) thrownewDomainError('...');
this._status = OrderStatus.PLACED;
// ❌ The Order now directly triggers three external side effectsawaitthis.inventoryService.reserve(this._items); // What if this fails?awaitthis.paymentService.capture(this.total); // What if payment fails after inventory?awaitthis.emailService.sendConfirmation(this._customerId); // Tight coupling, no retry
}
}
Count the problems:
Domain object depends on application services — Order is no longer a pure domain object. It imports InventoryService and PaymentService, which import Stripe and Redis. The Order class can no longer be instantiated in a unit test without a live payment processor.
No partial failure handling — if inventoryService.reserve() succeeds but paymentService.capture() throws, the inventory is reserved but the order is never paid. The domain is now in an inconsistent state with no compensation mechanism.
Order of side effects is hardcoded — if the business decides to send the confirmation email before capturing payment (to inform the customer while payment processes asynchronously), the domain object must be modified.
Adding a new side effect requires modifying the Order — when the fraud detection team wants to run a risk check when an order is placed, they open Order.ts and add another call. The Order class grows without bound.
Domain Events solve all four problems simultaneously.
A Domain Event is a fact that happened in the domain, named in past tense using the Ubiquitous Language of the business:
✅ Domain Event (fact, past tense)
❌ Not a Domain Event
OrderPlaced
PlaceOrder (that is a Command)
PaymentCaptured
PaymentCapture (ambiguous)
SubscriptionRenewed
RenewSubscriptionEvent (redundant suffix)
CustomerAccountSuspended
UserStatusChanged (too generic — no domain language)
A Domain Event is not a command (don't call OrderPlaced "a request to place an order"). It is not a notification (don't call it an "event notification"). It is a fact: something happened, it cannot be undone, and interested parties may react to it.
// src/domain/shared/DomainEvent.tsimport { randomUUID } from'node:crypto';
exportabstractclassDomainEvent {
/** Globally unique event ID for idempotency checks */publicreadonlyeventId: string;
/** When the event occurred — set at the moment the domain method executes */publicreadonlyoccurredAt: Date;
/** The ID of the Aggregate Root that raised this event */publicreadonlyaggregateId: string;
/** Schema version — used for event versioning and migration (Part 11) */publicreadonlyeventVersion: number;
protectedconstructor(aggregateId: string, eventVersion: number = 1) {
this.eventId = randomUUID();
this.occurredAt = newDate();
this.aggregateId = aggregateId;
this.eventVersion = eventVersion;
}
/** Type discriminator — used by event handlers for routing */abstractgeteventType(): string;
}
Events are collected — not dispatched — during domain method execution:
TYPESCRIPT
// src/domain/order/Order.tsexportclassOrderextendsAggregateRoot<OrderId> {
place(): void {
// Guard clauses firstif (this._status !== OrderStatus.PENDING)
thrownewDomainError(`Order ${this.id} cannot be placed — status is ${this._status}`);
if (this._items.length === 0)
thrownewDomainError(`Order ${this.id} cannot be placed with no line items`);
if (!this._shippingAddress)
thrownewDomainError(`Order ${this.id} requires a shipping address`);
// State transitionthis._status = OrderStatus.PLACED;
// ✅ Raise the event — do NOT dispatch it// The event is queued internally; the Use Case dispatches it after the DB committhis.addDomainEvent(newOrderPlaced(
this.id,
this._customerId,
this.total,
this._items.length,
));
}
cancel(reason: string): void {
const cancellableStatuses = [OrderStatus.PENDING, OrderStatus.PLACED];
if (!cancellableStatuses.includes(this._status))
thrownewDomainError(`Order ${this.id} cannot be cancelled — status is ${this._status}`);
this._status = OrderStatus.CANCELLED;
this.addDomainEvent(newOrderCancelled(this.id, reason));
}
}
The addDomainEvent() method (inherited from AggregateRoot) pushes the event onto an internal private array. The method returns synchronously. No side effects occur. The Order object has no knowledge of what will happen to the event.
The event bus interface is defined in the Application layer (it could also be in Domain — the key is that it is defined at or inside the layer that uses it, not in Infrastructure):
TYPESCRIPT
// src/application/shared/IEventBus.tsimport { DomainEvent } from'../../domain/shared/DomainEvent';
exportinterfaceIEventHandler<T extendsDomainEvent> {
handle(event: T): Promise<void>;
}
exportinterfaceIEventBus {
/** Dispatch a single domain event to all registered handlers */publish(event: DomainEvent): Promise<void>;
/** Register a handler for a specific event type */
subscribe<T extendsDomainEvent>(
eventType: string,
handler: IEventHandler<T>,
): void;
}
This implementation is synchronous and in-process. For development and testing, this is sufficient. For production (high-throughput billing), the Kafka-backed implementation dispatches to a durable message broker with at-least-once delivery (covered in Part 11 with the Outbox Pattern).
Notice: OrderConfirmationEmailHandler is in the Application layer (src/application/). It depends on ICustomerRepository and IEmailPort — both Domain interfaces. It knows nothing about nodemailer or SendGrid. Those implementations are in Infrastructure.
// ❌ Anti-Pattern: Dispatch events BEFORE the database commitsasyncexecute(command: PlaceOrderCommand): Promise<OrderId> {
const order = Order.create(command.customerId);
order.place();
// ❌ Events dispatched before save() — if save() fails, events already firedconst events = order.pullDomainEvents();
for (const event of events) awaitthis.eventBus.publish(event);
awaitthis.orderRepo.save(order); // If this throws, events were already dispatchedreturn order.id;
}
If orderRepo.save() fails (database connection drops, optimistic concurrency conflict), the events have already been dispatched. The InventoryReservationHandler has already reserved inventory for an order that was never saved. The confirmation email has already been sent. The billing system is now in an inconsistent state.
// ✅ Correct: Dispatch events AFTER the database commitsasyncexecute(command: PlaceOrderCommand): Promise<OrderId> {
// 1. Load or create the Aggregateconst order = Order.create(command.customerId);
for (const item of command.items) {
order.addItem(item.productId, item.quantity, item.unitPrice);
}
order.setShippingAddress(command.shippingAddress);
// 2. Execute domain logic — events are COLLECTED here, not dispatched
order.place();
// 3. Persist the Aggregate — this is the COMMIT pointawaitthis.orderRepo.save(order);
// 4. Pull events AFTER successful commitconst domainEvents = order.pullDomainEvents();
// 5. Dispatch events — handlers see a state that is now permanently in the databasefor (const event of domainEvents) {
awaitthis.eventBus.publish(event);
}
return order.id;
}
Step 3 is the transaction boundary. Steps 4 and 5 happen only if Step 3 succeeds. If the database commit fails (Step 3 throws), Steps 4 and 5 are never reached — no events are dispatched for an order that was never saved.
Crucial Requirement
The post-commit dispatch guarantee is only eventually consistent, not atomic. If the server crashes between Step 3 (successful DB commit) and Step 5 (event dispatch), the events are lost. For billing systems that require at-least-once event delivery, the Outbox Pattern (Part 11) writes events to the database in the same transaction as the Aggregate save, and a separate background process publishes them durably. For the purposes of Parts 5 through 10, in-process post-commit dispatch is sufficient.
These three tests verify all three critical behaviors: events are raised on success, events are not raised when the domain rejects the command, and events are not raised when persistence fails. Each runs in under 2ms with no external dependencies.
In Part 6, we build the Application layer — implementing PlaceOrderUseCase, CancelOrderUseCase, and ProcessRefundUseCase as thin orchestrators that coordinate Domain and Infrastructure without containing any business rules themselves. Part 6: Application Layer →
Research & Synthesis Note
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.