Observer
The producer stops knowing its consumers. That is the point and the price: nothing in the code shows what happens when the event fires.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Order confirmation needs to send an email, update inventory and notify analytics. Should the order module call those, or announce something and let them listen?
When an order is confirmed, five things must happen. Three of them are owned by other teams, two are allowed to fail without failing the order, and marketing keeps asking to add a sixth.
Call them. confirmOrder calls the mailer, the inventory service, the analytics client, in order, and you can read exactly what happens by reading one function.
The order module now imports marketing, analytics and email, so the dependency graph runs the wrong way and a marketing change can break checkout (Dependency Cycles).
- The order module now imports marketing, analytics and email, so the dependency graph runs the wrong way and a marketing change can break checkout (Dependency Cycles).
- Failures are entangled: the email provider being down fails the order, because the calls are in one sequence with one error path (Partial Failure).
- Every new consumer is an edit to
confirmOrder, made by a team that does not own it, which is a queue and a review bottleneck rather than a technical problem (Code Ownership). - Testing order confirmation requires stubbing five collaborators, so the test is about wiring rather than about orders (Mocking).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The order module must not gain a dependency on the marketing module — the dependency direction is a standing architectural decision (Dependency Direction).
- Inventory must be decremented in the same transaction as the confirmation; the email must not be (Where the Transaction Boundary Goes).
- Everything runs in one process today; a subset may move out later (The Modular Monolith).
- Inventory and order state agree. There is no confirmed order whose stock was not taken (Consistency Boundaries).
- An event is never delivered for an order that was not actually confirmed — no announcement before the commit (The Transactional Outbox).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The order module owns confirming orders and announcing that it happened. It owns nothing about who cares.
- Each consumer owns its own reaction, its own failure handling and its own retries (Retries Are a Property of the Operation).
- Something must own the *event contract* — its name, its fields, its compatibility — because it is now an interface with unknown consumers (Versioned Interfaces).
- The event is the boundary and it is a published contract the moment a second consumer exists. Treat its shape with the care of an API (What an API Contract Actually Is).
- The transactional boundary must be decided explicitly: inventory inside, email outside. Events that must be consistent with the state change are not observers, they are part of the transaction (Where the Transaction Boundary Goes).
- Synchronous in-process events and asynchronous queued events look identical at the call site and have completely different failure semantics. Do not let one dispatcher offer both (Request/Response vs Event-Driven).
What you gain and what disappears
The two halves of the diagram are the same behaviour. On the left, a reader of confirmOrder can see everything that happens; on the right, they can see nothing, and the marketing team can add a handler without asking anyone.
The dashed edges are the honest part: there is no static path from the emit to any handler. Your IDE will not find them, the compiler will not check them, and a newcomer tracing an order will stop at the emit line (Local Reasoning).
- Inventory stays a direct call because it must happen. Making a required step an observer is how stock guarantees get lost (Invariant Leaks).
- The emit happens after commit, or consumers act on an order that does not exist (The Transactional Outbox).
- Each handler owns its own failure. "The order succeeded and the email did not" must be a representable state (Partial Failure).
The event is a contract with strangers
The moment a second team consumes an event, its shape is an API — with the unusual property that you cannot enumerate its consumers, so you cannot check who a change breaks. That is a stronger constraint than a function signature, not a weaker one.
Past-tense naming is not a style preference. OrderConfirmed says something happened and leaves the reaction to the reader; SendConfirmationEmail is an instruction, which means the producer has decided what the consumer does and the decoupling was never real (Commands vs Events).
1type OrderConfirmed = {2 name: 'order.confirmed'3 version: 1 // it is a contract; version it4 orderId: OrderId5 confirmedAt: Instant6 total: Money // enough to act on, not a model copy7}8 9async function confirmOrder(id: OrderId) {10 const order = await tx(async (t) => {11 const o = await orders.confirm(id, t)12 await inventory.take(o.lines, t) // required: inside the transaction13 return o14 })15 await events.publish(orderConfirmed(order)) // announced only after commit16 return order17}18 19// Handlers live in their own modules and register themselves.20// Nothing above this line knows any of them exist.Two decisions carry the design: inventory is a call and not an event, and the publish is outside the transaction block. Reverse either and the invariants in this lesson fail in ways no unit test will catch.
Where event systems go wrong
None of these are exotic. Each is a normal consequence of the pattern working as designed, appearing months after adoption, in a codebase where the original decision looked obviously correct.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Nobody can say what happens on confirmation | Tracing an order stops at the emit line | No static path from producer to consumer — the pattern's defining property | A generated handler registry, a naming convention and a correlation id through every handler (Stable Identifiers). |
| A handler throws | The order fails, minutes after it was confirmed | Synchronous dispatch with no isolation between handlers | Isolate each handler; decide per handler whether failure is visible or retried (Error Boundaries). |
| Handler B assumes handler A ran | Works for a year, breaks after a registration-order change | Order dependence in a system that promises none | If order matters they are one operation, not two observers (Temporal Coupling). |
| The event payload keeps growing | Every model change is now an event change | Consumers asking for fields instead of fetching | Keep the payload to the identity and the facts of the event; let consumers read the rest (Response Contracts Are Not Database Rows). |
| A handler emits an event | One confirmation triggers a cascade of nine handlers | Composition nobody drew and nobody owns | Forbid handler-to-event chains, or make the chain an explicit orchestrated process (Saga Pattern). |
| A dead handler | Code that has not run in two years and cannot be deleted | No compiler error for an unregistered or unreached handler | Emit a metric per handler; anything at zero for a quarter is a deletion candidate (Using Observability, Not Building It). |
How to build it
Most important first.
- Name the event for what happened, in past tense, in domain vocabulary:
OrderConfirmed. An event named for what should happen next —SendConfirmationEmail— is a command with an event's syntax (Commands vs Events). - Publish after commit, not during. An event emitted inside a transaction that then rolls back is a lie that consumers cannot detect (The Transactional Outbox).
- Keep the payload self-contained enough that a consumer does not have to call back for the basics, and small enough that it is not a copy of your model (Response Contracts Are Not Database Rows).
- Keep the consumer list discoverable. A registry, a naming convention or a generated map — anything that lets a reader answer "who handles this" without a full-text search (Debuggability by Design).
- Do not use events for things that must happen. Inventory is a direct call inside the transaction, because "usually happens" is not a stock guarantee (Invariant Leaks).
- Prefer a direct call when there is one consumer, it is in the same module, and it must succeed. That is not coupling to be avoided; it is the honest shape of a required step (Local Reasoning).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Named change — "marketing wants a loyalty-points handler": one new handler in the marketing module, zero edits to orders, no cross-team review. This is the change the pattern is for and the saving is organisational as much as technical.
- Named change — "add a field to the event": now a compatibility question with unknown consumers, versioned like an API. Under direct calls this was a two-line refactor the compiler checked (Backward Compatibility as a Constraint).
- Named change — "why did this customer not get their email": significantly more expensive, forever. There is no call graph to follow; you need logs, correlation ids and knowledge of which handlers exist (Stable Identifiers).
- The honest summary: events make adding consumers cheap and make understanding, changing and debugging the flow more expensive. Choose it when consumers are added often and by other people, not because coupling sounds bad.
- You trade a readable call graph for an extensible one. That trade is good when consumers are numerous and owned elsewhere, and bad when there are two and you own both.
- Error handling gets harder: each handler needs its own policy, and "the order succeeded but two side effects failed" is a state you now have to represent (Partial Failure).
- The event contract is harder to change than a function signature, because you cannot enumerate its callers (API Stability).
What can go wrong
- Untraceable flow:
emit(OrderConfirmed)has no static path to any handler, so understanding what happens requires runtime knowledge. This is the pattern's defining cost (Local Reasoning). - A consumer throws and takes down the producer, because the dispatcher is synchronous and nobody isolated failures.
- Ordering assumptions creep in: the analytics handler assumes inventory already ran, and it does, until the day handler registration order changes (Temporal Coupling).
- Cascading events: a handler emits another event, which has a handler that emits another, and a single confirmation now runs a graph nobody has drawn (Dependency Cycles).
- The mitigation fails too: making everything async and retried means a failure surfaces minutes later in a dead-letter queue with no user-visible signal, which is not obviously better than failing loudly (A Dead-Letter Queue Is a Workflow, Not a Bin).
- The producer depends on the event type only. Consumers depend on the event type and on the dispatcher. Nothing points from the producer to a consumer, which is the whole benefit.
- The inversion is real but it is not free: the producer now depends on a *contract with unknown consumers*, which is a stronger constraint on change than a known import would be (API Stability).
- The dispatcher becomes a dependency of everything, and its semantics — ordered or not, sync or async, at-least-once or at-most-once — are now global properties of the system (Event-Driven Backends).
- "Events decouple, so events are better." They move coupling from the code to a contract, which is looser and less checkable. Looser coupling is not free — it is traded against traceability, and this domain's whole method is naming what a trade costs (Kinds of Coupling).
- "Use an event bus inside a module." Within one module the consumers are known and the call is a call. An event bus for local calls is the canonical over-application of this pattern (Pattern Overuse).
- "Events make it async." Only if the dispatcher is. In-process synchronous dispatch is still a blocking call chain with the same latency and the same failure propagation, just harder to see (Request/Response vs Event-Driven).
- "Everything that happens after an order should be an event." Things that must happen are part of the operation. Inventory is not an observer of confirmation; it is part of confirming (Consistency Boundaries).
- shotgun-surgery
- god-object
Testing it, and how it ages
- Test that the producer emits the right event with the right payload — that is the producer's whole contract now.
- Test each handler directly with a constructed event. Handlers should be pure functions of the event plus their own dependencies (What a Unit Is).
- One integration test per critical path that the whole chain runs, because nothing else covers the wiring and the wiring is where this pattern fails (Where a Test Must Be Real).
- A test that the event is not emitted when the transaction rolls back, which is the invariant most likely to be violated silently.
- Event systems accumulate handlers and never lose them. After three years, some handlers are dead and nobody can prove it, because there is no compiler error for an unused event (Speculative Generality).
- The payload grows as consumers ask for fields, until the event is a serialised copy of the aggregate and every model change is an event change (Aggregates).
- The usual next step is moving from in-process dispatch to a broker, which changes delivery semantics from exactly-once-ish to at-least-once and makes every handler an idempotency problem (Idempotency in Backends).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThe trade — producer ignorance bought with traceability — holds in every language and at every grain, from a UI event handler to a Kafka topic. What changes with grain is the delivery guarantee, not the trade.
- SCALE-SPECIFICBelow one team, direct calls are almost always better: the consumers are known, the import is honest, and the compiler checks the contract. The pattern starts paying when consumers are owned by other teams and adding one would otherwise require a review from the producer's owners (Code Ownership).
- CONTESTEDThe strongest argument for pervasive events is that they are how you keep a monolith modular under organisational pressure: teams can add behaviour without touching each other's code, which is often the binding constraint rather than any technical one. The strongest argument against is that a codebase where control flow is invisible cannot be reasoned about by newcomers at all, and that "loosely coupled" frequently describes a system nobody understands. Both positions are held by experienced people who have shipped large systems.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — the same trade at network grain, where the dispatcher becomes a broker and at-least-once delivery makes every handler an idempotency problem.