Mediator Pattern — Code Challenges
Challenge 1 — The Tangled Cart
The Code
Adding an item to the cart updates inventory, the order summary, and a badge — all from inside cart.add.
const inventory = {
stock: { "mug-1": 10, "hat-2": 3 },
decrement(sku) {
this.stock[sku] -= 1;
console.log(`[inventory] ${sku} → ${this.stock[sku]} left`);
return this.stock[sku];
},
};
const summary = {
total: 0,
warnings: [],
updateTotal(amount) {
this.total = amount;
console.log(`[summary] total: $${amount}`);
},
addWarning(msg) {
this.warnings.push(msg);
console.log(`[summary] warning: ${msg}`);
},
};
const badge = {
count: 0,
setCount(n) {
this.count = n;
console.log(`[badge] ${n} items`);
},
};
const cart = {
items: [],
add(item) {
this.items.push(item);
const qtyLeft = inventory.decrement(item.sku);
if (qtyLeft < 5) summary.addWarning(`${item.sku} low (${qtyLeft})`);
summary.updateTotal(this.total());
badge.setCount(this.items.length);
},
total() {
return this.items.reduce((s, i) => s + i.price, 0);
},
};
cart.add({ sku: "mug-1", name: "Mug", price: 12 });
cart.add({ sku: "hat-2", name: "Hat", price: 24 });What’s Wrong
Run this and ask: what happens when product adds a loyalty-points module that also reacts to add — or analytics that tracks every cart change?
You open cart.add and wire in another import. The cart module knows inventory thresholds, summary formatting, and badge rules. Tests for cart.add must stub three collaborators.
Three problems:
- Colleagues call each other directly — cart imports inventory, summary, and badge
- Every new reaction edits cart — the cart becomes an accidental coupling hub
- Cart owns others’ rules — low-stock threshold (
< 5) lives in cart, not inventory
Your Task
Refactor so that:
- A
createShoppingMediator()owns aroutestable mapping events to target colleagues - Colleagues register with the mediator and implement
onNotify(event, payload) createCart(mediator)only emitsITEM_ADDEDandTOTAL_CHANGEDviamediator.notify— no imports of inventory or summarycreateInventorydecrements stock and emitsSTOCK_LOWback through the mediator when qty< 5createSummarylistens forTOTAL_CHANGEDandSTOCK_LOWonly
Target interface:
const mediator = createShoppingMediator();
const cart = createCart(mediator);
const inventory = createInventory(mediator);
const summary = createSummary(mediator);
cart.add({ sku: "mug-1", name: "Mug", price: 12 });
// [inventory] mug-1 → 9 left
// [summary] total: $12
cart.add({ sku: "hat-2", name: "Hat", price: 24 });
// [inventory] hat-2 → 2 left
// [summary] warning: hat-2 low
// [summary] total: $36Hints
#1 — Cart emits; others listen
The cart should not know that inventory exists. After items.push(item), call:
mediator.notify("cart", "ITEM_ADDED", item)
and
mediator.notify("cart", "TOTAL_CHANGED", { total: cart.total() })
Inventory registers for ITEM_ADDED in its onNotify. Summary registers for TOTAL_CHANGED and STOCK_LOW. The cart never imports them.
#2 — Sketch the shape
function createShoppingMediator() {
const routes = {
ITEM_ADDED: ["inventory"],
TOTAL_CHANGED: ["summary"],
STOCK_LOW: ["summary"],
};
const colleagues = new Map();
return {
register(name, colleague) { colleagues.set(name, colleague); },
notify(from, event, payload) {
for (const name of routes[event] ?? []) {
colleagues.get(name)?.onNotify(event, payload);
}
},
};
}
function createCart(mediator) {
const items = [];
const cart = {
add(item) {
items.push(item);
mediator.notify("cart", "ITEM_ADDED", item);
mediator.notify("cart", "TOTAL_CHANGED", { total: cart.total() });
},
total() { return items.reduce((s, i) => s + i.price, 0); },
onNotify() {},
};
mediator.register("cart", cart);
return cart;
}Move low-stock detection into createInventory — inventory owns stock rules.
#3 — Pseudocode walkthrough
mediator routes:
ITEM_ADDED → [inventory]
TOTAL_CHANGED → [summary]
STOCK_LOW → [summary]
cart.add(item):
push item
notify("cart", "ITEM_ADDED", item)
notify("cart", "TOTAL_CHANGED", { total })
inventory.onNotify("ITEM_ADDED", item):
decrement stock[item.sku]
if stock below 5 → notify("inventory", "STOCK_LOW", { sku, qty })
summary.onNotify(event, payload):
if TOTAL_CHANGED → update total display
if STOCK_LOW → add warningAdding analytics later: extend routes.ITEM_ADDED to include "analytics" — cart unchanged.
Solution
View solution
function createShoppingMediator() {
const routes = {
ITEM_ADDED: ["inventory"],
TOTAL_CHANGED: ["summary"],
STOCK_LOW: ["summary"],
};
const colleagues = new Map();
return {
register(name, colleague) {
colleagues.set(name, colleague);
},
notify(from, event, payload) {
const targets = routes[event];
if (!targets) throw new RangeError(`unknown event: ${event}`);
for (const name of targets) {
colleagues.get(name)?.onNotify(event, payload);
}
},
};
}
function createCart(mediator) {
const items = [];
const cart = {
add(item) {
items.push(item);
mediator.notify("cart", "ITEM_ADDED", item);
mediator.notify("cart", "TOTAL_CHANGED", { total: cart.total() });
},
total() { return items.reduce((s, i) => s + i.price, 0); },
onNotify() {},
};
mediator.register("cart", cart);
return cart;
}
function createInventory(mediator) {
const stock = { "mug-1": 10, "hat-2": 3 };
const inventory = {
onNotify(event, item) {
if (event !== "ITEM_ADDED") return;
stock[item.sku] -= 1;
console.log(`[inventory] ${item.sku} → ${stock[item.sku]} left`);
if (stock[item.sku] < 5) {
mediator.notify("inventory", "STOCK_LOW", { sku: item.sku, qty: stock[item.sku] });
}
},
};
mediator.register("inventory", inventory);
return inventory;
}
function createSummary(mediator) {
let total = 0;
const warnings = [];
const summary = {
onNotify(event, payload) {
if (event === "TOTAL_CHANGED") {
total = payload.total;
console.log(`[summary] total: $${total}`);
}
if (event === "STOCK_LOW") {
warnings.push(`${payload.sku} low (${payload.qty})`);
console.log(`[summary] warning: ${payload.sku} low`);
}
},
getSnapshot() { return { total, warnings }; },
};
mediator.register("summary", summary);
return summary;
}
const mediator = createShoppingMediator();
const cart = createCart(mediator);
const inventory = createInventory(mediator);
const summary = createSummary(mediator);
cart.add({ sku: "mug-1", name: "Mug", price: 12 });
cart.add({ sku: "hat-2", name: "Hat", price: 24 });
console.log(summary.getSnapshot());
// { total: 36, warnings: ['hat-2 low (2)'] }What changed:
- Cart emits events; inventory and summary react via
onNotify— no cross-imports - Low-stock logic moved to inventory; routing table lives in the mediator
- Adding a colleague means one
registercall and a routes entry — not editingcart.add
Concepts at work: The Mediator owns routing rules (routes table). Colleagues are registrants that emit and receive named events. Domain logic stays in colleagues; the mediator only forwards. Contrast with Orchestrator: there is no fixed validate → charge sequence — peers react to each other’s events.
Challenge 2 — The Direct Dropdowns
The Code
Country, state, and city fields import each other. Changing country resets dependents by calling their methods directly.
function createCountryField(stateField, cityField) {
let value = "";
return {
set(next) {
value = next;
stateField.reset(next);
cityField.reset();
},
get() { return value; },
};
}
function createStateField(cityField) {
let value = "";
const options = { US: ["CA", "NY"], CA: ["ON", "BC"] };
return {
set(next) {
value = next;
cityField.reset();
},
reset(country) {
value = "";
console.log(`[state] reset for ${country}`);
},
optionsFor(country) { return options[country] ?? []; },
get() { return value; },
};
}
function createCityField() {
let value = "";
return {
reset() {
value = "";
console.log(`[city] reset`);
},
get() { return value; },
};
}
const city = createCityField();
const state = createStateField(city);
const country = createCountryField(state, city);
state.set("NY");
country.set("US");
// [state] reset for US
// [city] resetAdding a postal-code field means threading another reference through every constructor.
What’s Wrong
Each field constructor takes dependencies on fields it must notify. The dependency graph mirrors the UI hierarchy — country knows state and city; state knows city. Circular imports become likely as the form grows.
Three problems:
- Constructor coupling —
createCountryField(state, city)wires the graph at creation time - Reset logic scattered — country calls
stateField.reset; state callscityField.reset - No plug-in seam — a fourth field cannot subscribe to
COUNTRY_CHANGEDwithout editing country
Your Task
Refactor so that:
- A
createFormMediator()exposesregister(name, colleague)andnotify(from, event, payload) - Each field registers itself and calls
mediator.notifyon change — never calls another field directly - State listens for
COUNTRY_CHANGEDand resets; city listens forCOUNTRY_CHANGEDandSTATE_CHANGED - Adding a field means one new colleague — country and state stay unchanged
Target interface:
const mediator = createFormMediator();
const country = createCountryField(mediator);
const state = createStateField(mediator);
const city = createCityField(mediator);
state.set("NY");
country.set("US");
// [state] reset for US
// [city] reset
console.log(state.optionsFor("US")); // ['CA', 'NY']Hints
#1 — Mediator replaces constructor wiring
Fields no longer receive sibling references. Each factory takes only mediator:
createCountryField(mediator), createStateField(mediator), createCityField(mediator).
When country changes, call mediator.notify("country", "COUNTRY_CHANGED", { country: value }). State and city react in onNotify — country never calls state.reset().
#2 — Sketch the shape
function createFormMediator() {
const colleagues = new Map();
return {
register(name, colleague) { colleagues.set(name, colleague); },
notify(from, event, payload) {
for (const [name, colleague] of colleagues) {
if (name === from) continue;
colleague.onNotify(event, payload);
}
},
};
}
function createCountryField(mediator) {
let value = "";
const field = {
set(next) {
value = next;
mediator.notify("country", "COUNTRY_CHANGED", { country: value });
},
onNotify() {},
get() { return value; },
};
mediator.register("country", field);
return field;
}State’s onNotify handles COUNTRY_CHANGED; city’s handles both COUNTRY_CHANGED and STATE_CHANGED.
#3 — Pseudocode walkthrough
mediator = createFormMediator()
country.set(value):
store value
notify("country", "COUNTRY_CHANGED", { country: value })
state.onNotify("COUNTRY_CHANGED", payload):
clear state value
log reset for payload.country
state.set(value):
store value
notify("state", "STATE_CHANGED", { state: value })
city.onNotify(event):
if COUNTRY_CHANGED or STATE_CHANGED → clear city value, log reset
// No field imports another field
// Postal code field: register + onNotify for STATE_CHANGED onlyThe mediator broadcasts to all other colleagues — each decides whether to react to the event.
Solution
View solution
function createFormMediator() {
const colleagues = new Map();
return {
register(name, colleague) {
colleagues.set(name, colleague);
},
notify(from, event, payload) {
for (const [name, colleague] of colleagues) {
if (name === from) continue;
colleague.onNotify(event, payload);
}
},
};
}
function createCountryField(mediator) {
let value = "";
const field = {
set(next) {
value = next;
mediator.notify("country", "COUNTRY_CHANGED", { country: value });
},
onNotify() {},
get() { return value; },
};
mediator.register("country", field);
return field;
}
function createStateField(mediator) {
let value = "";
const options = { US: ["CA", "NY"], CA: ["ON", "BC"] };
const field = {
set(next) {
value = next;
mediator.notify("state", "STATE_CHANGED", { state: value });
},
onNotify(event, payload) {
if (event === "COUNTRY_CHANGED") {
value = "";
console.log(`[state] reset for ${payload.country}`);
}
},
optionsFor(country) { return options[country] ?? []; },
get() { return value; },
};
mediator.register("state", field);
return field;
}
function createCityField(mediator) {
let value = "";
const field = {
onNotify(event) {
if (event === "COUNTRY_CHANGED" || event === "STATE_CHANGED") {
value = "";
console.log(`[city] reset`);
}
},
get() { return value; },
};
mediator.register("city", field);
return field;
}
const mediator = createFormMediator();
const country = createCountryField(mediator);
const state = createStateField(mediator);
const city = createCityField(mediator);
state.set("NY");
console.log(city.get()); // ''
country.set("US");
// [state] reset for US
// [city] reset
console.log(state.optionsFor("US")); // ['CA', 'NY']What changed:
- Fields take only
mediator— no sibling references in constructors - Reset logic lives in each colleague’s
onNotify; country only emits events - A new dependent field registers once and filters events it cares about
Concepts at work: This is broadcast routing — every colleague except the sender hears the event and filters locally. Contrast with Challenge 1’s targeted routes table. Both are Mediator shapes; choose targeted routes when not everyone should hear every event. Contrast with Observer: observers subscribe to one subject; here peers communicate through a shared hub without knowing subscribers.
When Not to Use the Mediator Pattern
A mediator earns its place when peers must react to each other without direct imports. Skip it when:
- The flow is a fixed sequence. Checkout validate → charge → receipt is an Orchestrator — steps run in order, not by peer events.
- One source broadcasts and many listen passively. A store emitting
changeto UI components is Observer — no routing rules between peers. - Only two modules interact. A direct call or injected callback may be simpler than a mediator hub.
- The mediator absorbs domain logic. If
notifyimplements inventory math and pricing, move logic back to colleagues — the mediator should route, not compute.
The rule of thumb: if peers need to react to each other’s events without knowing who else exists, route through a Mediator. If you write “first A, then B, then C” in order, use an Orchestrator.
What to Take Away
Before moving on, answer these from memory:
- In Challenge 1, why does low-stock detection belong in
createInventoryrather thancreateCart? - What is the difference between Challenge 1’s
routestable and Challenge 2’s broadcast-to-all-other-colleagues approach? - When would you choose a Mediator over an Orchestrator for a checkout flow — and when would Orchestrator win?
- Your mediator’s
notifyhandler has grown to 200 lines of business logic. What do you refactor first?