Mediator
An order form with five fields does not sound like a hard problem. This one had a bug that would not sit still. Change the quantity and the price updated, unless you had ticked gift wrapping first, in which case the price was right but Submit stayed greyed out. Untick gift wrapping and Submit came back, and now the bulk-discount hint was stale. Every fix I shipped just moved the bug to a different pair of fields.
Here is why. Each field’s change handler reached straight into the other fields and updated a hand-picked slice of the form:
const qty = document.querySelector("#qty");
const giftWrap = document.querySelector("#gift-wrap");
const coupon = document.querySelector("#coupon");
const submit = document.querySelector("#submit");
const total = document.querySelector("#total");
const bulkHint = document.querySelector("#bulk-hint");
qty.addEventListener("input", () => {
const n = Number(qty.value);
total.textContent = price(n, giftWrap.checked, coupon.value);
submit.disabled = n < 1 || !couponOk(coupon.value);
bulkHint.hidden = n < 10;
});
giftWrap.addEventListener("change", () => {
const n = Number(qty.value);
total.textContent = price(n, giftWrap.checked, coupon.value);
// ...and that is all. no submit. no bulkHint. nobody noticed for a month.
});
coupon.addEventListener("input", () => {
const n = Number(qty.value);
total.textContent = price(n, giftWrap.checked, coupon.value);
submit.disabled = n < 1 || !couponOk(coupon.value);
});
Three handlers. Each one re-derives the same price from the same three inputs, and each one carries its own private idea of what a change should touch. The gift-wrap handler updates the price and stops there, because whoever wrote it was thinking about money, not about the button. So a gift-wrap change leaves submit.disabled frozen at whatever the last quantity change happened to set it to. That is the bug, and there are as many of them as there are pairs of fields.
Every part knows every part
Strip the form away and the shape is general. You have a handful of parts that react to each other, and you wired them the obvious way: each one holds a reference to the ones it affects and pokes them directly. Five parts, and you are up to ten wires. Add a sixth and it does not cost you one wire, it costs you five, because the newcomer can affect any of the others and any of them can affect it. The connection count climbs with the square of the parts, n(n-1)/2 if you want the exact curve, and every new part has to be introduced to everyone already in the room.
A mediator cuts every one of those direct wires and replaces them with one apiece. Each part talks to a single object in the middle and to nothing else. The parts stop knowing each other and start knowing one coordinator, and the coordinator becomes the only thing that holds the rules about who affects whom. That is the entire trade: many small couplings become one central one.
Route it through one object
For the form, the coordinator is small. It holds the form’s state, and it has exactly one function that rebuilds the whole UI from that state.
// order-form.js: the fields no longer know each other exist
function createOrderForm(ui) {
const state = { qty: 1, giftWrap: false, coupon: "" };
function update() {
const base = state.qty * 12 + (state.giftWrap ? 3 : 0);
const valid = couponOk(state.coupon);
ui.total.textContent = format(valid ? applyCoupon(base, state.coupon) : base);
ui.submit.disabled = state.qty < 1 || !valid;
ui.bulkHint.hidden = state.qty < 10;
ui.couponError.hidden = state.coupon === "" || valid;
}
update(); // paint the initial state once
return {
changed(field, value) {
state[field] = value;
update();
},
};
}
The fields get wired to it and to nothing else:
const form = createOrderForm(ui);
qty.addEventListener("input", () => form.changed("qty", Number(qty.value)));
giftWrap.addEventListener("change", () => form.changed("giftWrap", giftWrap.checked));
coupon.addEventListener("input", () => form.changed("coupon", coupon.value));
Read what changed. Every field now says one thing, “this value is different now,” and hands it to changed. It does not decide what that means. The mediator writes the new value into state and runs update, which recomputes the total, the button, the hint, and the error from scratch, every single time. The gift-wrap bug is not fixed so much as made unbuildable. There is exactly one line that sets submit.disabled, and it runs on every change, so no field can forget to touch it. No field touches it at all.
Recomputing everything from one state object on every change is the same discipline a render function or a reducer uses, and it buys the same thing: no half-updated screen, because there is no code path that updates half the screen. The rules live together in update, where you can read the whole interaction at once instead of reconstructing it from six scattered handlers.
Play with both versions below. In Mediator mode every change runs one update, so the screen is always coherent. Flip to Naive and the handlers go back to poking outputs directly, each one remembering a different subset of the rules. The gap to trigger is written under the form.
A room instead of a mesh
The form is one-mediator, many-fields. The pattern earns its keep just as hard the other direction, where many peers all need to reach many peers. A chat room is the clean example. Ten people in a room is not ninety wires between ten user objects. It is ten wires to one room.
function createChatRoom() {
const members = new Map(); // name -> deliver(msg)
return {
join(name, deliver) {
members.set(name, deliver);
return () => members.delete(name); // hand back the leave
},
send(from, text) {
for (const [name, deliver] of members) {
if (name !== from) deliver({ from, text });
}
},
};
}
const room = createChatRoom();
room.join("bob", (m) => render(m));
room.join("carol", (m) => render(m));
room.send("alice", "standup in five"); // bob and carol get it, alice does not
Alice does not hold a reference to Bob. She does not know Bob is in the room, or that Carol exists, or how many people will read her message. She hands one string to the room and the room decides who receives it. The moment you want a new rule, mute a spammer, deliver a direct message to exactly one person, drop everything from a banned user, that rule goes in send, in one place, instead of being copied into every user object that might send a message.
This is air traffic control, and the metaphor is worth taking literally. Pilots do not negotiate runways peer to peer. Every aircraft in the zone talks to one tower, the tower holds the rules, and the tower is the reason two planes are not cleared for the same strip of tarmac. Let the planes coordinate directly and you have a mesh, and a mesh of aircraft is a specific and famous kind of disaster. A game server routing player actions, a collaborative editor merging edits, a wizard whose steps constrain each other: all the same instinct. Put the coordination in the middle, keep the peers ignorant of each other.
Mediator, observer, or a bus?
These blur together, and the observer and pub/sub article built the machinery all three share: a thing in the middle holding a list and notifying it. The difference that matters is how much brain lives in that middle thing.
A pub/sub broker is deliberately brainless. It routes a message to whoever subscribed to that name and holds no opinion about what the message means. Publish "order.paid", and the bus forwards it, unchanged, to every subscriber. That is a mediator with the coordination logic thrown away, which is exactly why it scales to many-to-many across modules: there is nothing in the middle to become a bottleneck of decisions.
A mediator keeps the brain. It does not just pass "quantity changed" along, it decides that a quantity change means the submit button re-enables and the bulk hint appears. The intelligence is the point. Same wiring, more knowledge in the hub.
In practice these mix. A very common real-world mediator is a plain object that subscribes to a bus, listens for a handful of events, and holds the coordination rules that react to them. The bus does the routing, the mediator does the thinking, and that is a fine split. What a mediator is not is a chain of responsibility or a middleware onion: those thread one request down a line where each link can handle it or pass it on, which is a pipe, not a hub. And a plain global bus with no hub logic, used to broadcast facts across an app or across services, is the seed of an event-driven architecture, not a mediator at all.
The god object, which is the whole risk
Here is the sharp edge, and it is sharp enough that a lesson skipping it would be lying to you. The mediator solves coupling by centralizing it. Push that one step too far and the coordinator becomes the thing that knows everything: every field, every rule, every special case, every module, all in one file. You did not delete the mesh. You moved it inside the mediator, where it is now a two-thousand-line update with a hundred branches, and every change risks breaking an unrelated feature because it all shares one scope.
The tell is easy to spot once you know it. The mediator imports half the app. Its update method is a wall of if across unrelated concerns. Two engineers cannot touch it in the same week without a merge conflict. You cannot describe what it does in one sentence without the word “and” three times. That is not a mediator anymore. It is a god object, and it is the exact anti-pattern the mediator was supposed to prevent, just relocated.
The fix is to keep each mediator focused on one cohesive slice of coordination and split by area the moment it sprawls. A checkout page is better served by a pricing mediator and a shipping mediator that each do a describable job than by one CheckoutManager that owns the universe. They can still talk, but each has a boundary you can hold in your head. This is the over-centralising trap: centralization feels like tidying, so every time you route one more thing through the hub the individual change looks like cleanup, and the aggregate is a monster. A mediator with one job is a tool. A mediator with everyone’s job is a rewrite waiting to happen.
When it helps, and when it is just ceremony
The honest failure mode of this pattern is not the god object. It is reaching for the pattern at all when you did not need it.
The mediator starts paying for itself around three or four interacting parts, once the rules of who-affects-whom stop being obvious and start changing together. Interdependent forms, a room of peers, a wizard with cross-step constraints, UI where enabling one control depends on the state of two others: those are real many-to-many tangles, and centralizing them buys you a single readable place for the rules and the freedom to add a part without editing five others.
The test I actually use: if I cannot add or change one component without touching several of its neighbours, the coupling is real and a mediator earns its cost. If everything is already one-to-one and stable, there is no tangle to remove, and a mediator would only add one.
Summary
- Wire N components directly and connections grow with N squared; every new part must be introduced to all the others. A mediator replaces those direct wires with one apiece: each part talks only to a central coordinator, which holds the rules about who affects whom.
- The clean move for interdependent UI is a coordinator that holds state and has one
updatethat rebuilds everything from that state on every change. Half-updated screens become unbuildable because there is no path that updates half the screen. - In modern JavaScript the mediator is a factory closing over state and a
changedcallback, not aMediatorinterface plus aColleaguebase class. The widgets never inherit anything; they are handed a way to report in. Composition, not a class diagram. - A chat room or air-traffic-control tower is the many-peers case: members never reference each other, the hub routes and holds the rules. Add a routing rule in one place instead of copying it into every peer.
- Pub/sub is a mediator with the brain removed: it forwards messages by name and coordinates nothing, which is why it scales. A mediator keeps the coordination logic in the hub. Same wiring, different amount of knowledge in the middle. A state store is a mediator on the write side and an observer on the read side.
- The god object is the whole risk. Keep routing everything through one coordinator and the mesh reappears inside it as a two-thousand-line file that knows everything. Keep each mediator focused; split by area when it sprawls. Centralizing feels like tidying, which is exactly why it runs away from you.
When to use: genuinely many-to-many coordination (four or more interdependent parts) whose rules change together and would otherwise smear across every part. When to avoid: two or three parts with obvious, stable wiring; route them directly and skip the hub. And the second a coordinator can no longer be described in one sentence, split it before it becomes the tangle it was meant to remove.