// ───────────────────────────────────────────────────────────────────────────── // MEDIATOR [Behavioral] // ───────────────────────────────────────────────────────────────────────────── // INTENT : Define an object that encapsulates how a set of objects interact; promotes loose coupling by // keeping objects from referring to each other explicitly. // PROBLEM : A checkout form: country select resets the region select, zip validates against country, submit // enables only when all valid — N widgets each holding references to the others is a hairball. // SAY : Participants: Mediator (interface: notify(sender, event)), ConcreteMediator (knows all // colleagues, owns the coordination rules), Colleague (talks only to the mediator). Pitch: "hub // and spoke — peers never reference each other; all traffic goes through the coordinator." // SEEN IN : React "lift state up" — a parent coordinating sibling components via props/callbacks; Flux // Dispatcher / Redux store as the hub components dispatch into; Socket.io server & rooms (clients // never talk peer-to-peer); react-hook-form `control` coordinating registered fields; chat servers. // VS : Observer = one Subject broadcasting to anonymous listeners; Mediator = many colleagues talking // THROUGH one object that holds the LOGIC (often built with Observer underneath). Facade is one-way // simplification of a subsystem; Mediator is two-way coordination among peers. // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); // ── Classic shape (classes, so the participant names are visible) class Widget { // Colleague base — knows only its mediator constructor(name) { this.name = name; this.mediator = null; this.enabled = true; } changed(event, payload) { this.mediator?.notify(this, event, payload); } } class Select extends Widget { constructor(name, options = []) { super(name); this.options = options; this.value = null; } setOptions(opts) { this.options = opts; this.value = null; } select(v) { this.value = v; this.changed('select', v); } } class TextInput extends Widget { constructor(name) { super(name); this.value = ''; this.error = null; } type(v) { this.value = v; this.changed('input', v); } } class Button extends Widget { constructor(name) { super(name); this.enabled = false; this.clicks = 0; } click() { if (this.enabled) { this.clicks += 1; this.changed('click'); } } } class CheckoutFormMediator { // ConcreteMediator — ALL cross-widget rules live here static regions = { US: ['CA', 'NY'], CA: ['ON', 'BC'] }; static zipRe = { US: /^\d{5}$/, CA: /^[A-Z]\d[A-Z] ?\d[A-Z]\d$/ }; constructor({ country, region, zip, submit }) { Object.assign(this, { country, region, zip, submit, submitted: [] }); for (const w of [country, region, zip, submit]) w.mediator = this; region.enabled = false; } notify(sender, event, payload) { if (sender === this.country && event === 'select') { this.region.setOptions(CheckoutFormMediator.regions[payload] ?? []); this.region.enabled = this.region.options.length > 0; this.zip.value = ''; this.zip.error = null; } if (sender === this.zip && event === 'input') { const re = CheckoutFormMediator.zipRe[this.country.value]; this.zip.error = re && !re.test(payload) ? `invalid postal code for ${this.country.value}` : null; } if (sender === this.submit && event === 'click') { this.submitted.push({ country: this.country.value, region: this.region.value, zip: this.zip.value }); } this.submit.enabled = Boolean(this.country.value && this.region.value && this.zip.value && !this.zip.error); } } const country = new Select('country', ['US', 'CA']); const region = new Select('region'); const zip = new TextInput('zip'); const submit = new Button('submit'); const form = new CheckoutFormMediator({ country, region, zip, submit }); assert.equal(region.enabled, false); country.select('US'); assert.deepEqual(region.options, ['CA', 'NY']); // country reconfigured region — they never met assert.equal(region.enabled, true); region.select('NY'); zip.type('1234'); assert.equal(zip.error, 'invalid postal code for US'); assert.equal(submit.enabled, false); zip.type('10001'); assert.equal(zip.error, null); assert.equal(submit.enabled, true); submit.click(); assert.deepEqual(form.submitted, [{ country: 'US', region: 'NY', zip: '10001' }]); country.select('CA'); // switching country resets dependents assert.equal(region.value, null); assert.equal(zip.value, ''); assert.equal(submit.enabled, false); submit.click(); assert.equal(submit.clicks, 1); // disabled button ignored the click // ── Idiomatic JS shape: a chat room — participants hold a handle to the room, never to each other const createChatRoom = () => { const members = new Map(); // name → inbox return { join(name) { const inbox = []; members.set(name, inbox); return { inbox, say: (text) => { for (const [n, box] of members) if (n !== name) box.push(`${name}: ${text}`); }, dm: (to, text) => { if (!members.has(to)) throw new Error(`${to} not in room`); members.get(to).push(`${name} (dm): ${text}`); }, leave: () => members.delete(name), }; }, get size() { return members.size; }, }; }; const room = createChatRoom(); const ada = room.join('ada'); const bob = room.join('bob'); const cy = room.join('cy'); ada.say('deploy at 5?'); bob.dm('ada', 'yes'); assert.deepEqual(bob.inbox, ['ada: deploy at 5?']); assert.deepEqual(cy.inbox, ['ada: deploy at 5?']); assert.deepEqual(ada.inbox, ['bob (dm): yes']); // sender doesn't receive own broadcast cy.leave(); bob.say('ok'); assert.equal(cy.inbox.length, 1); // departed members stop receiving assert.throws(() => ada.dm('cy', 'x'), /cy not in room/); assert.equal(room.size, 2); console.log('ok: design-patterns/behavioral/mediator.js');