Design patternBehavioral Patterns
Observer
Let an object announce its changes to a list of subscribers it does not know, so new reactions plug in without editing the source of the change.
- Complexity
- Medium
- Seen in the wild
- High
- Also known as
- Publish-Subscribe, Event Listener, Dependents
Intent
Give an object (the subject) a list of subscribers and notify each of them when its state changes. The subject knows only that its subscribers implement one small interface. It does not know what they do with the news, how many there are, or who they are.
Forces
- One change vs many reactions: a stock level drops, and the storefront, the purchasing team and the audit log all care, each for its own reason.
- A stable core vs reactions that keep arriving: every new reaction should not mean another line inside the method that made the change.
- Loose coupling vs traceable control flow: removing direct calls also removes the call site a reader would follow.
- Long-lived subjects vs short-lived listeners: a subject that holds references to its listeners keeps them alive until someone detaches them.
- Synchronous delivery vs isolation: a slow or throwing listener runs inside the subject's call and can stall or break it.
Therefore
Have the subject keep a list of objects that implement a notification interface, and call each of them after every relevant change.
Subscribers register and unregister themselves at runtime. The subject builds one event value that describes the change and passes it to each one. In languages with first-class functions, a subscriber is usually a callback, and subscribe returns a function that removes it. Write an interface only when a subscriber reacts to several kinds of events.
Structure
Diagram source (Mermaid)
classDiagram
class StockLedger {
-List observers
+subscribe(o) Unsubscribe
+adjust(sku, delta) void
}
class StockObserver {
<<interface>>
+onStockChanged(change) void
}
class ReorderPlanner {
-int reorderPoint
+onStockChanged(change) void
}
class StorefrontAvailability {
+onStockChanged(change) void
}
class AuditTrail {
+onStockChanged(change) void
}
StockLedger o-- StockObserver : notifies
StockObserver <|.. ReorderPlanner
StockObserver <|.. StorefrontAvailability
StockObserver <|.. AuditTrailParticipants
- Subject (
StockLedger): owns the state, keeps the subscriber list, and notifies after each change. It never checks a subscriber's concrete type. - Observer (
StockObserver): the notification interface. Keep it to one method per event kind. - Concrete observers (
ReorderPlanner,StorefrontAvailability,AuditTrail): each reacts to the event in its own way and knows nothing about the others. - Event (
StockChange): an immutable value that describes what changed, so observers don't need to call back into the subject. - Client: wires observers to the subject and detaches them when they go away.
Use it when
- A method ends with a growing list of "also tell X" calls that have nothing to do with its main job.
- Code in a lower layer must notify code in a higher layer without importing it, such as a domain model notifying a UI or a cache.
- The set of interested parties changes at runtime: plugins, open browser tabs, connected sockets.
- You need to add reactions (metrics, audit, cache invalidation) without editing or redeploying the code that causes the change.
Do not use it when
- The events are plain announcements and a platform mechanism already covers them. Use
EventEmitterorEventTargetin JavaScript, signals in a UI framework, C# events, or a channel in Go - a hand-rolled subject duplicates them and gets the edge cases wrong. Write your own subject only when you need filtering, ordering or in-process delivery rules those mechanisms don't have. - There is exactly one reaction and it will not change. Call it directly; the call site documents the dependency.
- The order of reactions matters, or one reaction must finish before the next starts. Observers should not depend on each other's order. Write the sequence as plain code, or use Chain of Responsibility.
- You need delivery across processes, retries or persistence. Use a message queue or an outbox table. An in-memory observer list loses events on crash.
- You can't guarantee that every listener unsubscribes. A long-lived subject, such as a global store, holds a strong reference to each listener and leaks every component that forgot to unsubscribe. If you can't guarantee an unsubscribe path, use scoped subscriptions (framework effects with cleanup,
AbortSignal, context cancellation) or weak references.
Application grill
Inspect first: existing notification mechanisms (emitters, signals, streams, framework subscriptions) and the direct calls they would replace; whether any subscriber must not miss an event - that single requirement changes the infrastructure decision.
Missing pieces: in-process: the language's built-ins - nothing to buy. Cross-process or durable: a broker - Redis pub/sub for transient fan-out; SQS, SNS or Kafka when a subscriber must not miss events (that is a queue with consumer groups, not pub/sub).
Ask before you build:
- Scale: how many subscribers and what event rate - what happens when one subscriber lags (backpressure)?
- Performance: must delivery be synchronous within the request, or is eventual delivery acceptable per subscriber?
- Reliability: one failing subscriber must not fail the others - what isolates them (retries, dead letters)?
- Budget: managed broker versus in-process only - what tips the decision?
- Implementation: who owns event schemas and their versioning when payloads change?
Implementation steps
- Find the method that makes the change, and the calls to other objects at its end that only react to it.
- Define an immutable event value with everything those reactions read. Don't make observers query the subject.
- Define the observer interface, or a callback type, with one method per event kind.
- Give the subject a collection of observers, a
subscribemethod that returns an unsubscribe handle, and a privatenotify. - Move each reaction into its own observer and subscribe it at the composition root.
- Iterate over a copy of the list during
notify, so an observer can unsubscribe while being notified. - Decide what happens when an observer throws: let it propagate, or catch it, log it and continue. Write that decision down in a test.
- Pair every
subscribewith an unsubscribe on the subscriber's teardown path.
Example
A warehouse ledger tracks stock per SKU. When a level changes, the purchasing planner may reorder, the storefront hides sold-out items, and an audit trail records the move.
interface StockChange {
sku: string;
previous: number;
onHand: number;
}
interface StockObserver {
onStockChanged(change: StockChange): void;
}
class StockLedger {
private readonly levels = new Map<string, number>();
private readonly observers = new Set<StockObserver>();
subscribe(observer: StockObserver): () => void {
this.observers.add(observer);
return () => this.observers.delete(observer);
}
adjust(sku: string, delta: number): void {
const previous = this.levels.get(sku) ?? 0;
const onHand = previous + delta;
this.levels.set(sku, onHand);
const change: StockChange = { sku, previous, onHand };
// Copy so an observer can unsubscribe during notification.
for (const observer of [...this.observers]) observer.onStockChanged(change);
}
}
class ReorderPlanner implements StockObserver {
constructor(private readonly reorderPoint: number) {}
onStockChanged({ sku, previous, onHand }: StockChange): void {
if (previous > this.reorderPoint && onHand <= this.reorderPoint) {
console.log(`reorder ${sku}: ${onHand} left`);
}
}
}
class StorefrontAvailability implements StockObserver {
readonly hidden = new Set<string>();
onStockChanged({ sku, onHand }: StockChange): void {
if (onHand <= 0) this.hidden.add(sku);
else this.hidden.delete(sku);
}
}
class AuditTrail implements StockObserver {
readonly entries: string[] = [];
onStockChanged({ sku, previous, onHand }: StockChange): void {
this.entries.push(`${sku} ${previous}->${onHand}`);
}
}
const ledger = new StockLedger();
const storefront = new StorefrontAvailability();
const audit = new AuditTrail();
ledger.subscribe(new ReorderPlanner(5));
ledger.subscribe(storefront);
const stopAudit = ledger.subscribe(audit);
ledger.adjust("KETTLE-01", 8);
ledger.adjust("KETTLE-01", -4); // reorder KETTLE-01: 4 left
stopAudit(); // detach on teardown, or the ledger keeps the trail alive
ledger.adjust("KETTLE-01", -4);
console.log([...storefront.hidden]); // [ 'KETTLE-01' ]
console.log(audit.entries); // [ 'KETTLE-01 0->8', 'KETTLE-01 8->4' ]Trade-offs
Gains
- New reactions are new code; the subject does not change.
- The subject depends on one interface, not on every consumer, so lower layers stay free of upper-layer imports.
- Subscriptions can change at runtime.
- Each observer is testable alone by feeding it an event value.
Costs
- Control flow is implicit. A reader of
adjustcan't see what runs after it without finding everysubscribecall. - Forgotten unsubscribes leak memory and cause "zombie" handlers that act on torn-down objects.
- Notification order is usually unspecified, and code that relies on it breaks silently.
- A throwing or slow observer runs on the subject's call stack and affects every caller of the subject.
- An observer that changes the subject during notification can trigger cascades or infinite loops.
Relations
- Mediator centralizes how a group of objects talk to each other. It is often implemented with observers, but a mediator knows its participants; a subject does not.
- Chain of Responsibility passes a request along until one handler deals with it, in a defined order. Observer delivers to every subscriber, in no order you should rely on.
- Command turns a request into an object. Combine the two when events must be queued, replayed or logged before observers see them.
- Singleton is how a global event bus usually starts. Avoid it: a global subject hides every dependency and holds every listener for the life of the process.
Agent checklist
- You checked for an existing event mechanism in the platform or framework before writing a subject.
- Every
subscribehas a matching unsubscribe on the subscriber's teardown path, verified by a test or a cleanup hook. - The event is an immutable value with all the data observers need; observers don't call back into the subject.
- No observer depends on running before or after another one.
- Notification iterates over a copy of the list, and the behavior when an observer throws is decided and tested.
- The existing tests still pass, and each observer has a test that feeds it an event directly.
See also
- MediatorRoute interactions between a group of components through one coordinator so they stop referencing each other and the rules live in one place.
- Chain of ResponsibilityPass a request along a chain of handlers until one decides it, so the sender never knows which handler acts or how many exist.
- CommandWrap an action and its arguments in an object so you can queue, log, retry or undo it separately from the code that triggers it.
- SingletonGuarantee one instance of a class per process with a global access point. Usually better replaced by one instance created at startup and injected.
Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.