Engineering Atlas › Low-Level Design

Mediator pattern

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem

A checkout form has four controls: a country select, a region select, a shipping-method select and a "terms accepted" checkbox, plus a Place order button. The rules are simple to state. Region options depend on the country, and the region is cleared when the country changes. Some countries have no regions. Express shipping exists only in some countries. The button is enabled only when everything required is filled in.

The first implementation wires the controls to each other. The country handler clears the region, reloads its options, filters shipping and re-checks the button; the other three handlers each re-check the button too. Every control holds references to three or four others, the "is the form complete?" logic is copied four times, and the copies disagree. A new promo code field that affects shipping means hunting for every handler that should consider it.

Put the rules in the middle

With a mediator, each control knows one thing: whom to tell when its value changes.

class Widget:
    def set(self, value):
        self._check(value)          # disabled? valid option?
        if value != self.value:
            self.value = value
            self.mediator.changed(self)

The form object is the mediator. Its changed(widget) method is the only place that knows the rules: if the sender is the country, reset the region, load its options and filter shipping; in every case, recompute whether the button is enabled. Controls are now reusable (the same select works on the address book page with a different mediator) and testable alone. The rules can be read top to bottom in one method.

Mediator, Observer, Facade, event bus

These are easy to confuse because they all reduce direct references.

A useful test: if you can point to one place and say "this is where the rules about how these components interact live", you have a mediator.

Where you already use it

Keeping the mediator from becoming a god object

A mediator absorbs complexity on purpose, so it grows. Three habits keep it healthy. First, keep components ignorant: they report what happened ("value changed") and never what should happen next ("please reset the region"). Second, split by cluster: an address section mediator and a payment section mediator, each owning their rules, with a thin form-level mediator joining them. Third, when rules become numerous and regular, express them as data (a dependency table: "region depends on country; shipping depends on country and weight") and let the mediator evaluate the table.

Reviewing agent-written code

Asked to "clear the region when the country changes", an agent will add regionSelect.clear() to the country's change handler: one more direct link. Asked for something more decoupled, it often reaches for a global event bus with string topics, which hides the links instead of removing them. In review, ask where the interaction rules live and whether a component imports or calls a sibling. Check for re-entrancy: does setting a value from inside the mediator trigger the mediator again? Check that the "is it complete?" logic exists once.

The same shape elsewhere

A mediator is a contention and ordering answer: every interaction goes through one point, so interactions are serialised and applied in a known order. The same trade appears in distributed systems as coordinator versus peer-to-peer designs. A coordinator (a leader, a lock service like etcd, an orchestrator for a saga) is easy to reason about and becomes a bottleneck and a single point of failure; gossip and choreography avoid the bottleneck and make the global behaviour harder to see. The choice inside a form and the choice across 40 services follow the same reasoning.

Down the stack

Layer 1The contract

Components promise to report their own changes to the mediator and to never reference sibling components. The mediator promises that after it finishes handling any notification, the group's invariants hold: the region is valid for the country, shipping is valid for the country, and the button is enabled exactly when the form is complete. It also promises that its own updates to components do not generate new notifications, so handling one user action runs the rules once. Handling is synchronous and in a defined order, so a test can set a value and immediately assert the whole group's state.

Layer 2The mechanism

The structure is a star: the mediator holds typed references to each component, and each component holds one reference to the mediator, usually through a small interface (changed(sender)) so components do not depend on the concrete form. Re-entrancy is handled in one of two ways. Either components expose a silent setter that the mediator uses (the practice solution's approach), or the mediator sets an "updating" flag and ignores notifications that arrive while it is applying rules.

In React, the parent's state setter schedules a re-render rather than mutating children; React 18 batches the updates from one event into one render, and children receive new props. In Kubernetes, controllers use informers that list and watch resources into a local cache and queue the keys that changed; reconciliation writes through the API server with optimistic concurrency, where a stale resourceVersion fails with a conflict and the controller retries. The API server never calls controllers; the interaction emerges from shared state. In Go programs, a common in-process mediator is a single goroutine that owns the state and serves requests from channels in a select loop, so no lock is needed because only one goroutine touches the data.

Layer 3The machine

The star costs N references instead of up to N(N-1)/2, and each interaction costs one indirect call into the mediator plus the rule evaluation, a few nanoseconds in Go and well under a microsecond in a browser. The memory effect worth knowing is cycles: components point to the mediator and the mediator points to components. Tracing collectors (V8, the JVM, Go) reclaim such cycles once nothing outside reaches them, but CPython frees most objects by reference counting and needs its cyclic collector to reclaim them, which runs later. A long-lived global mediator, like a lapsed observer, keeps every registered component alive until it is explicitly removed.

Concurrency is where the mediator's centrality shows. Because every interaction passes through it, it is a natural serialisation point: one mutex or one owner goroutine. That makes it correct by construction and limits throughput to one interaction at a time. If each interaction holds the lock for 20 microseconds, the ceiling is 50,000 interactions per second no matter how many cores there are. An uncontended Go channel send and receive costs on the order of 100 ns, cheap in a form and noticeable in a market data path. When the ceiling bites, partition: one mediator per order book, per chat room, per document, so unrelated interactions do not contend.

Why it's built this way

  1. Components that react to each other directly need links that grow quadratically with their number.
  2. Interaction rules scattered across handlers disagree and cannot be read as a whole, so they move to one object.
  3. Components then know only that object, which makes them reusable and testable alone.
  4. One object applying all rules serialises interactions, which makes the rules easy to reason about and makes the mediator a bottleneck and a growth hotspot.
  5. So mediators are kept per cluster or per partition, and kept free of re-entrant cascades.

Diagram

sequenceDiagram
  participant U as User
  participant C as Country select
  participant M as Form mediator
  participant R as Region select
  participant S as Shipping select
  participant B as Order button
  U->>C: set("ID")
  C->>M: changed(country)
  M->>R: load options, clear value (silent)
  M->>S: options [standard], drop "express" (silent)
  M->>B: enabled = false
  Note over M: one notification, rules applied once

Worked example

A travel site's booking page had 14 interactive controls (dates, travellers, cabin, fare family, seats, bags, insurance, promo code and others). An audit counted 31 change handlers reaching into other controls and 6 different copies of the "can the user continue?" check. Over two quarters, 9 production bugs came from these interactions, such as a promo code unlocking a fare family whose bag allowance was not re-applied when travellers changed.

The rewrite made every control report only its own changes, to a trip-section mediator or an extras-section mediator, joined by a page mediator that owns the continue check. The dependency rules became a table of 22 entries such as "bags depends on fare family and travellers", and sibling references per control fell from 3.2 on average to zero.

Interaction bugs fell to 1 in the next two quarters. Rule handling took about 0.3 ms per user action against a 16 ms frame budget, and a change that once touched 7 handlers became one table row and one test.

Trade-offs

OptionStrengthsWeaknessesChoose when
Direct references between componentsObvious for two or three partsQuadratic links; rules scattered and duplicatedTwo components with one interaction
Observer between componentsNo imports between partsRules still spread across subscribers; cascades hard to seeOne source, many independent reactions
Global event busMaximum decoupling; plugins can joinRules invisible; string topics; ordering surprisesCross-module notifications without shared policy
Mediator objectOne place for rules; components reusableCan grow into a god object; serialisation pointA cluster of components with real interaction rules
Central state store (Redux style)State and transitions explicit and replayableBoilerplate; everything globalLarge apps where many views share state

Pitfalls in production

Leadership lens

Check yourself

  1. How does a mediator differ from an observer?

    Show answer

    Observer is a broadcast mechanism with no policy about what reactions do. A mediator receives notifications and decides, by explicit rules, which other components change and how.

  2. Why must updates made by the mediator not notify the mediator again?

    Show answer

    Otherwise one user action triggers the rules repeatedly or recursively, which wastes work, can apply resets twice, and can loop forever when rules depend on each other.

  3. A chat server uses one mediator object for all rooms and slows down at peak. What do you change?

    Show answer

    Partition the mediator by room, so unrelated rooms do not contend on the same lock or goroutine, and keep any cross-room coordination separate and small.

  4. In what sense is the Kubernetes API server a mediator?

    Show answer

    Controllers never call each other; they watch and write shared objects through the API server, so all coordination between them happens through that one component and its stored state.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →