Observer lets an object announce that something happened without knowing who cares. It is the in-process ancestor of message queues, and it has the same appeal and the same hazards at a smaller scale.
The pattern is easy. What is hard — and what most treatments skip — is that publishing an event replaces one set of explicit problems with a different set of implicit ones, and you have to decide the implicit ones deliberately or they get decided for you.
The problem
async function placeOrder(order) {
await save(order);
await sendConfirmationEmail(order);
await updateInventory(order);
await notifyWarehouse(order);
await trackAnalytics(order);
}Order placement now depends on email, inventory, the warehouse and analytics. Every new reaction edits this function, and a failure in analytics prevents an order from completing.
async function placeOrder(order) {
await save(order);
events.emit('order.placed', order);
}
events.on('order.placed', sendConfirmationEmail);
events.on('order.placed', updateInventory);Diagram, as text
- placeOrder leads to Event bus (emits order.placed)
- Event bus leads to Send confirmation
- Event bus leads to Update inventory
- Event bus leads to Notify warehouse
- Event bus leads to Track analytics
What you gave up
Reading the code no longer tells you what happens. placeOrder is now two lines, and the answer to "what happens when an order is placed" lives in however many files subscribe. A newcomer cannot find it by reading; they must search for the event name and hope everyone spelled it the same way.
Ordering is unspecified. If inventory must be decremented before the warehouse is notified, nothing in this design says so. It works because of registration order, which is an accident of import order.
Failure semantics are undefined. If updateInventory throws, does the warehouse still get notified? Does the caller find out? Is the order still placed? A synchronous loop over subscribers usually means the first failure silently cancels the rest.
Refactoring gets harder. Static analysis cannot tell you that sendConfirmationEmail is called, so it looks unused, and renaming an event is a string search rather than a rename.
The decisions you must make explicitly
Synchronous or asynchronous. Synchronous means the publisher waits and failures can propagate — closer to a function call, and it makes latency the publisher's problem. Asynchronous means the publisher returns immediately and failures are invisible to it, which needs somewhere for them to go.
Failure isolation. One subscriber throwing must not prevent the others from running. Wrap each in its own error boundary, log with the event name and the subscriber, and decide whether the publisher hears about it at all.
Ordering. If order matters, the design must state it — priorities, or an explicit sequence, or a rethink about whether these are really independent observers. If order does not matter, say that too, so nobody accidentally introduces a dependency.
Delivery guarantees. In-process observers are at-most-once and vanish on a crash. If the reaction must survive a restart, an in-memory event bus is the wrong mechanism and the transactional outbox pattern is the right one.
Unsubscription. A subscriber holding a reference to a long-lived publisher is a memory leak. In long-running processes with dynamic subscribers — UI components, per-request handlers — the unsubscribe path must exist and must be exercised.
Push, pull, and payload size
Two options for what the notification carries.
Push sends the data with the event: emit('order.placed', order). Convenient, and it fixes the payload — a subscriber needing one more field forces a change to the publisher and every other subscriber's expectations.
Pull sends an identifier and lets subscribers fetch what they need: emit('order.placed', { orderId }). Each subscriber reads exactly what it wants, at the cost of a lookup, and with the risk of reading a later state than the event described.
That last risk is real and underappreciated: a subscriber that fetches by ID may see the order after a subsequent cancellation, and act on a state that never coexisted with the event. Push with a snapshot avoids it. For in-process observers, push a small immutable snapshot is usually the right default.
Check your understanding
Common mistakes
Using it for required steps. A failed listener that should have failed the transaction produces silent data corruption.
No per-subscriber error boundary. One throwing subscriber cancels the rest of the chain.
Relying on registration order. A real dependency, unstated, broken by an unrelated refactor.
Never unsubscribing. Subscribers held by a long-lived publisher leak, and stale ones keep reacting after they should have stopped.
Naming events as commands. order.sendEmail makes subscribers a to-do list and reintroduces the coupling you removed.
Expecting durability from an in-memory bus. Events vanish on crash; if the reaction must survive, use an outbox.
Emitting inside a database transaction. Subscribers may read state that is not committed yet, or react to something that then rolls back.
Practice
After moving to an event bus, an incident occurs: some orders were placed but never had inventory decremented, and nobody noticed for three days. Analyse the failure and describe what you would change.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
There are two failures here — one in the design and one in the operations.
Show a strong answer
The design failure: inventory decrement was never an observer. It is part of what makes an order valid. As a listener it can fail independently of the order being placed, which means the system's definition of a successful order stopped matching the business's. The event bus did not cause this; it made it possible, and the modelling decision is the actual defect.
The operational failure is worse, and it is the one to fix first. Three days without noticing means subscriber failures were not visible. That is a monitoring gap that would have hidden any listener's failure, not just this one, and it will hide the next.
Immediate actions:
- Find the affected orders and reconcile inventory, because the data is wrong right now.
- Alert on subscriber failure rate, per event and per subscriber. This is the control that was missing, and it takes an hour.
- Add a reconciliation check — a periodic job comparing orders placed against inventory movements. Even after the design is fixed, this is the thing that catches the next divergence, and it should be permanent.
The design change: move inventory into the operation.
- If both are in one database, decrement in the same transaction as the order. Then a failure means no order, which is the correct outcome.
- If inventory is a separate service, the reaction is not optional and it is not local, so it needs an explicit saga: place the order in a pending state, reserve inventory, confirm — with a compensating action if reservation fails. That is more work than a listener and it is the honest amount of work for the requirement.
- Keep the event.
order.placedis still useful for the genuinely optional reactions — email, analytics, the warehouse feed. The mistake was not having events; it was putting a required step among them.
The rule I would write down for the team: a listener may only do work whose failure leaves the system in a valid state. If the answer to "what if this never runs?" is "the data is wrong", it is not a listener.
What I would also check: which other listeners fail that test. This event probably has siblings, and the same reasoning was likely applied to all of them at once.
Design the contract for an in-process event bus used across a team: naming, payloads, ordering, failure handling and testing. Justify the choices that constrain people.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Every rule you write is a rule someone will want to break. Say why it holds.
Show a strong answer
Naming: past-tense facts, namespaced by aggregate. order.placed, payment.captured, user.emailChanged. Constants, never string literals at call sites, so a rename is a rename rather than a search.
Why it constrains usefully: the moment an event is named order.sendEmail, the publisher is issuing instructions and the coupling is back. The naming rule is the cheapest way to keep the distinction visible in review.
Payload: a small immutable snapshot, versioned. The fields a subscriber needs, frozen, with a schema. Not the live domain object.
Why: passing a mutable domain object lets one subscriber's mutation reach the next, which is the aliasing failure in its most confusing form. And a snapshot removes the read-later-state problem that identifiers alone create. The constraint people will push against is payload size — the answer is that a subscriber needing much more data is probably not an observer.
Ordering: unspecified, and enforced as unspecified. Subscribers must not depend on each other's order.
Why enforce rather than merely document: an accidental dependency works until it does not, and fails far from its cause. I would randomise subscriber order in test runs, which turns a latent ordering assumption into a failing test on someone's machine rather than an incident. If a genuine ordering requirement appears, that is a signal the steps are one operation, not several observers.
Failure handling: isolated, logged, counted, never propagated by default. Each subscriber runs in its own boundary; a throw is logged with event name, subscriber name and a correlation id, and increments a metric.
Why the metric is non-negotiable: isolation without observability is the design that hid the three-day inventory bug. Isolation makes failures survivable; the metric makes them visible.
Publication timing: after commit, never inside a transaction.
Why: a subscriber reading uncommitted state, or reacting to a transaction that then rolls back, produces effects for something that never happened. Where the reaction must survive a crash, use an outbox rather than the bus.
Testing: subscribers tested directly, publishers asserted on emission. A test for placeOrder asserts the event was emitted with the right payload; a test for the email subscriber calls it with a payload. Almost no test should exercise both through the bus.
Why: end-to-end-through-the-bus tests are slow, order-dependent and they hide exactly the coupling the bus was meant to remove.
The rule that will get pushback, and my answer: no subscriber may do work whose failure leaves the system invalid. People will want an exception "just this once" for something they do not want to put in the transaction. That exception is how the inventory incident happens, and the honest alternative is a saga.
Key takeaways
- Observer decouples one event from many reactions, at the cost of invisible coupling — reading the publisher no longer tells you what happens.
- The decisive question is whether a reaction is required for correctness. Required steps are not observers.
- Decide explicitly: sync or async, failure isolation, ordering, durability and unsubscription. Defaults get chosen for you otherwise.
- Never rely on registration order. Randomise it in tests so latent dependencies fail loudly.
- Push a small immutable snapshot rather than an identifier, or subscribers may act on a state that never coexisted with the event.
- Name events as past-tense facts. A command-shaped name reintroduces the coupling.
- Isolation without observability is how failures hide. Count and alert on subscriber errors.
- Emit after commit, and use an outbox when the reaction must survive a crash.
Further reading
- Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994).
- Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) — domain events and the observer's enterprise relatives.
- Gregor Hohpe and Bobby Woolf, Enterprise Integration Patterns (Addison-Wesley, 2003) — publish–subscribe and its delivery guarantees.
- Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013) — domain events and the transactional outbox.
- Michael Nygard, Release It!, second edition (Pragmatic Bookshelf, 2018) — failure isolation between components.