Episode 9 — System Design / 9.5 — Behavioral Design Patterns

9.5.h Mediator Pattern

Overview

The Mediator Pattern defines an object that encapsulates how a set of objects interact. Instead of objects referring to each other directly (many-to-many), they communicate through a central mediator (one-to-many). This promotes loose coupling by keeping objects from referring to each other explicitly.

Think of an air traffic control tower: planes don't communicate directly with each other. They all talk to the tower, and the tower coordinates everything.

+--------------------------------------------------------------+
|                    MEDIATOR PATTERN                            |
|                                                               |
|  WITHOUT MEDIATOR:             WITH MEDIATOR:                 |
|                                                               |
|    A <---> B                     A                            |
|    ^  \  / ^                     |                            |
|    |   \/  |                     v                            |
|    |   /\  |               +-----------+                      |
|    v  /  \ v               | MEDIATOR  |                      |
|    C <---> D               +-----------+                      |
|                              ^ | ^ |                          |
|  Every object knows          | | | |                          |
|  about every other.          B C D E                          |
|  N*(N-1)/2 connections      Each object only                  |
|                             knows the mediator.               |
|                                                               |
|  Many-to-Many              One-to-Many                        |
|  (6 connections)           (4 connections)                     |
+--------------------------------------------------------------+

The Problem: Many-to-Many Coupling

// BAD: Components directly reference each other
class LoginButton {
  constructor(loginForm, validationLabel, submitSpinner, forgotPasswordLink) {
    this.loginForm = loginForm;
    this.validationLabel = validationLabel;
    this.submitSpinner = submitSpinner;
    this.forgotPasswordLink = forgotPasswordLink;
  }

  click() {
    // LoginButton must know about ALL other components
    if (this.loginForm.isValid()) {
      this.submitSpinner.show();
      this.validationLabel.hide();
      this.forgotPasswordLink.disable();
      this.loginForm.submit();
    } else {
      this.validationLabel.show('Invalid credentials');
      this.submitSpinner.hide();
    }
    // Adding a new component (e.g., RememberMeCheckbox)
    // means modifying LoginButton!
  }
}

Problems:

  • Every component knows about every other component
  • Adding/removing a component requires changing multiple classes
  • Can't reuse components in different contexts
  • Extremely hard to test components in isolation

Implementation 1: Chat Room Mediator

// ============================================
// MEDIATOR INTERFACE
// ============================================
class ChatMediator {
  addUser(user) { throw new Error('Must implement'); }
  removeUser(user) { throw new Error('Must implement'); }
  sendMessage(message, sender) { throw new Error('Must implement'); }
  sendDirectMessage(message, sender, recipient) { throw new Error('Must implement'); }
}

// ============================================
// COLLEAGUE (Participant)
// ============================================
class ChatUser {
  constructor(name) {
    this.name = name;
    this.mediator = null;
    this.inbox = [];
    this.isOnline = true;
  }

  setMediator(mediator) {
    this.mediator = mediator;
  }

  send(message) {
    if (!this.mediator) throw new Error('Not connected to a chat room');
    console.log(`[${this.name}] sends: "${message}"`);
    this.mediator.sendMessage(message, this);
  }

  sendTo(message, recipientName) {
    if (!this.mediator) throw new Error('Not connected to a chat room');
    console.log(`[${this.name}] whispers to ${recipientName}: "${message}"`);
    this.mediator.sendDirectMessage(message, this, recipientName);
  }

  receive(message, from) {
    const entry = {
      from: from.name,
      message,
      timestamp: Date.now(),
      type: 'broadcast'
    };
    this.inbox.push(entry);
    console.log(`  [${this.name}] received from ${from.name}: "${message}"`);
  }

  receiveDirectMessage(message, from) {
    const entry = {
      from: from.name,
      message,
      timestamp: Date.now(),
      type: 'direct'
    };
    this.inbox.push(entry);
    console.log(`  [${this.name}] (DM from ${from.name}): "${message}"`);
  }

  goOffline() {
    this.isOnline = false;
    console.log(`[${this.name}] went offline`);
  }

  goOnline() {
    this.isOnline = true;
    console.log(`[${this.name}] came online`);
  }

  getInbox() {
    return [...this.inbox];
  }
}

// ============================================
// CONCRETE MEDIATOR: Chat Room
// ============================================
class ChatRoom extends ChatMediator {
  constructor(name) {
    super();
    this.name = name;
    this.users = new Map();  // userName -> ChatUser
    this.messageLog = [];
  }

  addUser(user) {
    if (this.users.has(user.name)) {
      console.log(`[${this.name}] ${user.name} is already in the room`);
      return;
    }

    this.users.set(user.name, user);
    user.setMediator(this);

    // Notify others
    for (const [name, existingUser] of this.users) {
      if (name !== user.name && existingUser.isOnline) {
        existingUser.receive(
          `${user.name} has joined the room`,
          { name: 'System' }
        );
      }
    }

    console.log(
      `[${this.name}] ${user.name} joined. ` +
      `${this.users.size} users in room.`
    );
  }

  removeUser(user) {
    this.users.delete(user.name);
    user.setMediator(null);

    // Notify others
    for (const [name, existingUser] of this.users) {
      if (existingUser.isOnline) {
        existingUser.receive(
          `${user.name} has left the room`,
          { name: 'System' }
        );
      }
    }

    console.log(`[${this.name}] ${user.name} left. ${this.users.size} users remaining.`);
  }

  sendMessage(message, sender) {
    this.messageLog.push({
      from: sender.name,
      message,
      timestamp: Date.now(),
      type: 'broadcast'
    });

    // Deliver to all OTHER online users
    for (const [name, user] of this.users) {
      if (name !== sender.name && user.isOnline) {
        user.receive(message, sender);
      }
    }
  }

  sendDirectMessage(message, sender, recipientName) {
    const recipient = this.users.get(recipientName);

    if (!recipient) {
      console.log(`  [${this.name}] User "${recipientName}" not found`);
      return;
    }

    if (!recipient.isOnline) {
      console.log(`  [${this.name}] ${recipientName} is offline. Message queued.`);
      // In a real app, you'd queue this for later delivery
      return;
    }

    this.messageLog.push({
      from: sender.name,
      to: recipientName,
      message,
      timestamp: Date.now(),
      type: 'direct'
    });

    recipient.receiveDirectMessage(message, sender);
  }

  getOnlineUsers() {
    return [...this.users.values()]
      .filter(u => u.isOnline)
      .map(u => u.name);
  }

  getMessageLog() {
    return [...this.messageLog];
  }
}

// ============================================
// USAGE
// ============================================
const generalRoom = new ChatRoom('General');

const alice = new ChatUser('Alice');
const bob = new ChatUser('Bob');
const charlie = new ChatUser('Charlie');
const diana = new ChatUser('Diana');

// Users join the room (through the mediator)
generalRoom.addUser(alice);
generalRoom.addUser(bob);
generalRoom.addUser(charlie);
generalRoom.addUser(diana);

// Communication happens THROUGH the mediator
alice.send('Hello everyone!');
// Bob, Charlie, Diana all receive the message
// But Alice doesn't directly reference any of them!

bob.sendTo('Hey Alice, got a minute?', 'Alice');
// Only Alice receives this

charlie.goOffline();
alice.send('Is everyone here?');
// Charlie doesn't receive (offline)

console.log('\nOnline users:', generalRoom.getOnlineUsers());
console.log('Message log:', generalRoom.getMessageLog().length, 'messages');

Implementation 2: Air Traffic Control

// ============================================
// AIR TRAFFIC CONTROL MEDIATOR
// ============================================

//  Flight Diagram:
//
//   Incoming Flights                Tower (Mediator)              Runways
//   +-----------+                  +---------------+          +-----------+
//   | Flight A  |-----request----->|               |------->| Runway 1  |
//   +-----------+    landing       |  ATC Tower    |         +-----------+
//   | Flight B  |-----request----->|               |
//   +-----------+    landing       | Manages:      |         +-----------+
//   | Flight C  |-----request----->| - Flight queue|-------->| Runway 2  |
//   +-----------+    takeoff       | - Runway avail|         +-----------+
//                                  | - Separation  |
//                                  +---------------+

class Aircraft {
  constructor(callsign, type) {
    this.callsign = callsign;
    this.type = type;  // 'commercial', 'cargo', 'private'
    this.altitude = 0;
    this.status = 'in-flight';  // 'in-flight', 'approaching', 'landing', 'taxiing', 'parked', 'departing'
    this.tower = null;
    this.assignedRunway = null;
  }

  setTower(tower) {
    this.tower = tower;
  }

  requestLanding() {
    if (!this.tower) throw new Error('No tower assigned');
    console.log(`[${this.callsign}] Requesting landing clearance`);
    this.tower.requestLanding(this);
  }

  requestTakeoff() {
    if (!this.tower) throw new Error('No tower assigned');
    console.log(`[${this.callsign}] Requesting takeoff clearance`);
    this.tower.requestTakeoff(this);
  }

  receiveClearance(message, runway) {
    console.log(`  [${this.callsign}] Roger: "${message}" (Runway ${runway?.id || 'N/A'})`);
    this.assignedRunway = runway;
  }

  receiveHoldOrder(message) {
    console.log(`  [${this.callsign}] Copy: "${message}" - Holding position`);
    this.status = 'holding';
  }

  land(runway) {
    this.status = 'landing';
    this.altitude = 0;
    console.log(`  [${this.callsign}] Landing on Runway ${runway.id}`);
    setTimeout(() => {
      this.status = 'taxiing';
      console.log(`  [${this.callsign}] Cleared runway, taxiing to gate`);
      this.tower.notifyRunwayCleared(runway, this);
    }, 0);
  }

  takeoff(runway) {
    this.status = 'departing';
    console.log(`  [${this.callsign}] Taking off from Runway ${runway.id}`);
    setTimeout(() => {
      this.status = 'in-flight';
      this.altitude = 10000;
      console.log(`  [${this.callsign}] Airborne, climbing to cruise altitude`);
      this.tower.notifyRunwayCleared(runway, this);
    }, 0);
  }
}

class Runway {
  constructor(id, length) {
    this.id = id;
    this.length = length;  // in feet
    this.occupied = false;
    this.currentAircraft = null;
  }

  assign(aircraft) {
    this.occupied = true;
    this.currentAircraft = aircraft;
  }

  clear() {
    this.occupied = false;
    this.currentAircraft = null;
  }

  isAvailable() {
    return !this.occupied;
  }
}

class ATCTower {
  constructor(airportCode) {
    this.airportCode = airportCode;
    this.aircraft = new Map();
    this.runways = [];
    this.landingQueue = [];
    this.takeoffQueue = [];
    this.log = [];
  }

  addRunway(runway) {
    this.runways.push(runway);
    console.log(`[ATC ${this.airportCode}] Runway ${runway.id} available (${runway.length}ft)`);
  }

  registerAircraft(aircraft) {
    this.aircraft.set(aircraft.callsign, aircraft);
    aircraft.setTower(this);
    console.log(`[ATC ${this.airportCode}] ${aircraft.callsign} registered (${aircraft.type})`);
  }

  requestLanding(aircraft) {
    const availableRunway = this._findAvailableRunway(aircraft);

    if (availableRunway) {
      this._clearForLanding(aircraft, availableRunway);
    } else {
      this.landingQueue.push(aircraft);
      aircraft.receiveHoldOrder(
        `Hold at current altitude. Position ${this.landingQueue.length} in queue.`
      );
      this._log('HOLD', aircraft.callsign, 'Landing queue');
    }
  }

  requestTakeoff(aircraft) {
    const availableRunway = this._findAvailableRunway(aircraft);

    if (availableRunway && this.landingQueue.length === 0) {
      // Landing priority over takeoff
      this._clearForTakeoff(aircraft, availableRunway);
    } else {
      this.takeoffQueue.push(aircraft);
      aircraft.receiveHoldOrder(
        `Hold position. ${this.landingQueue.length} aircraft landing first.`
      );
      this._log('HOLD', aircraft.callsign, 'Takeoff queue');
    }
  }

  notifyRunwayCleared(runway, aircraft) {
    runway.clear();
    this._log('CLEARED', aircraft.callsign, `Runway ${runway.id}`);

    // Process queues: landing priority
    if (this.landingQueue.length > 0) {
      const next = this.landingQueue.shift();
      this._clearForLanding(next, runway);
    } else if (this.takeoffQueue.length > 0) {
      const next = this.takeoffQueue.shift();
      this._clearForTakeoff(next, runway);
    }
  }

  _findAvailableRunway(aircraft) {
    return this.runways.find(r => r.isAvailable());
  }

  _clearForLanding(aircraft, runway) {
    runway.assign(aircraft);
    aircraft.receiveClearance(
      `Cleared to land Runway ${runway.id}. Wind 270 at 10.`,
      runway
    );
    aircraft.land(runway);
    this._log('LANDING', aircraft.callsign, `Runway ${runway.id}`);
  }

  _clearForTakeoff(aircraft, runway) {
    runway.assign(aircraft);
    aircraft.receiveClearance(
      `Cleared for takeoff Runway ${runway.id}. Climb to FL100.`,
      runway
    );
    aircraft.takeoff(runway);
    this._log('TAKEOFF', aircraft.callsign, `Runway ${runway.id}`);
  }

  _log(action, callsign, detail) {
    this.log.push({
      action,
      callsign,
      detail,
      timestamp: Date.now()
    });
  }

  getStatus() {
    return {
      airport: this.airportCode,
      runways: this.runways.map(r => ({
        id: r.id,
        available: r.isAvailable(),
        aircraft: r.currentAircraft?.callsign || null
      })),
      landingQueue: this.landingQueue.map(a => a.callsign),
      takeoffQueue: this.takeoffQueue.map(a => a.callsign),
      totalAircraft: this.aircraft.size
    };
  }
}

// ============================================
// USAGE
// ============================================
const tower = new ATCTower('JFK');
tower.addRunway(new Runway('31L', 14500));
tower.addRunway(new Runway('31R', 10000));

const flight1 = new Aircraft('AA101', 'commercial');
const flight2 = new Aircraft('UA202', 'commercial');
const flight3 = new Aircraft('FX303', 'cargo');
const flight4 = new Aircraft('N456P', 'private');

tower.registerAircraft(flight1);
tower.registerAircraft(flight2);
tower.registerAircraft(flight3);
tower.registerAircraft(flight4);

// Flights DON'T communicate with each other
// Everything goes through the tower (mediator)
console.log('\n--- Landing Sequence ---');
flight1.requestLanding();  // Gets runway 31L
flight2.requestLanding();  // Gets runway 31R
flight3.requestLanding();  // Queued (both runways busy)

console.log('\nATC Status:', JSON.stringify(tower.getStatus(), null, 2));

Implementation 3: UI Component Mediator

// ============================================
// UI MEDIATOR: Form Dialog
// ============================================
class DialogMediator {
  constructor() {
    this.components = new Map();
  }

  register(name, component) {
    this.components.set(name, component);
    component.setMediator(this);
    return this;
  }

  notify(sender, event, data) {
    // Central coordination logic
    const senderName = this._findName(sender);
    console.log(`[Mediator] ${senderName} -> ${event}`, data || '');

    switch (event) {
      case 'input:change':
        this._handleInputChange(senderName, data);
        break;
      case 'checkbox:toggle':
        this._handleCheckboxToggle(senderName, data);
        break;
      case 'button:click':
        this._handleButtonClick(senderName, data);
        break;
      case 'select:change':
        this._handleSelectChange(senderName, data);
        break;
    }
  }

  _handleInputChange(senderName, data) {
    const submitBtn = this.components.get('submitButton');
    const nameInput = this.components.get('nameInput');
    const emailInput = this.components.get('emailInput');

    // Enable submit only if both fields are filled
    if (nameInput && emailInput && submitBtn) {
      const isValid = nameInput.value.length > 0 && emailInput.value.includes('@');
      submitBtn.setEnabled(isValid);
    }

    // Update preview
    const preview = this.components.get('preview');
    if (preview && senderName === 'nameInput') {
      preview.setText(`Hello, ${data.value}!`);
    }
  }

  _handleCheckboxToggle(senderName, data) {
    if (senderName === 'termsCheckbox') {
      const submitBtn = this.components.get('submitButton');
      if (submitBtn) {
        submitBtn.setEnabled(data.checked);
      }
    }

    if (senderName === 'showAdvanced') {
      const advancedPanel = this.components.get('advancedPanel');
      if (advancedPanel) {
        advancedPanel.setVisible(data.checked);
      }
    }
  }

  _handleButtonClick(senderName, data) {
    if (senderName === 'submitButton') {
      const formData = this._collectFormData();
      console.log('[Mediator] Form submitted:', formData);

      // Disable everything during submission
      for (const [name, component] of this.components) {
        if (component.setEnabled) component.setEnabled(false);
      }

      // Show success
      const status = this.components.get('statusLabel');
      if (status) status.setText('Submitted successfully!');
    }

    if (senderName === 'cancelButton') {
      this._resetForm();
    }
  }

  _handleSelectChange(senderName, data) {
    if (senderName === 'roleSelect') {
      const adminPanel = this.components.get('adminPanel');
      if (adminPanel) {
        adminPanel.setVisible(data.value === 'admin');
      }
    }
  }

  _collectFormData() {
    const data = {};
    for (const [name, component] of this.components) {
      if (component.value !== undefined) {
        data[name] = component.value;
      }
    }
    return data;
  }

  _resetForm() {
    for (const [name, component] of this.components) {
      if (component.reset) component.reset();
    }
    console.log('[Mediator] Form reset');
  }

  _findName(sender) {
    for (const [name, component] of this.components) {
      if (component === sender) return name;
    }
    return 'unknown';
  }
}

// ============================================
// UI COMPONENTS (Colleagues)
// ============================================
class UIComponent {
  constructor() {
    this.mediator = null;
  }

  setMediator(mediator) {
    this.mediator = mediator;
  }
}

class TextInput extends UIComponent {
  constructor(placeholder = '') {
    super();
    this.value = '';
    this.placeholder = placeholder;
    this.enabled = true;
  }

  setValue(value) {
    this.value = value;
    console.log(`  [Input] Value: "${value}"`);
    // Notify mediator of change
    this.mediator?.notify(this, 'input:change', { value });
  }

  setEnabled(enabled) {
    this.enabled = enabled;
    console.log(`  [Input] ${enabled ? 'Enabled' : 'Disabled'}`);
  }

  reset() {
    this.value = '';
    this.enabled = true;
  }
}

class Button extends UIComponent {
  constructor(label) {
    super();
    this.label = label;
    this.enabled = false;
  }

  click() {
    if (!this.enabled) {
      console.log(`  [Button: ${this.label}] Disabled, cannot click`);
      return;
    }
    console.log(`  [Button: ${this.label}] Clicked!`);
    this.mediator?.notify(this, 'button:click', {});
  }

  setEnabled(enabled) {
    this.enabled = enabled;
    console.log(`  [Button: ${this.label}] ${enabled ? 'Enabled' : 'Disabled'}`);
  }

  reset() {
    this.enabled = false;
  }
}

class Checkbox extends UIComponent {
  constructor(label) {
    super();
    this.label = label;
    this.checked = false;
  }

  toggle() {
    this.checked = !this.checked;
    console.log(`  [Checkbox: ${this.label}] ${this.checked ? 'Checked' : 'Unchecked'}`);
    this.mediator?.notify(this, 'checkbox:toggle', { checked: this.checked });
  }

  reset() {
    this.checked = false;
  }
}

class Label extends UIComponent {
  constructor(text = '') {
    super();
    this.text = text;
  }

  setText(text) {
    this.text = text;
    console.log(`  [Label] "${text}"`);
  }

  reset() {
    this.text = '';
  }
}

class Panel extends UIComponent {
  constructor() {
    super();
    this.visible = true;
  }

  setVisible(visible) {
    this.visible = visible;
    console.log(`  [Panel] ${visible ? 'Shown' : 'Hidden'}`);
  }
}

// ============================================
// USAGE
// ============================================
const dialog = new DialogMediator();

dialog
  .register('nameInput', new TextInput('Enter name'))
  .register('emailInput', new TextInput('Enter email'))
  .register('termsCheckbox', new Checkbox('Accept terms'))
  .register('showAdvanced', new Checkbox('Show advanced'))
  .register('submitButton', new Button('Submit'))
  .register('cancelButton', new Button('Cancel'))
  .register('preview', new Label())
  .register('statusLabel', new Label())
  .register('advancedPanel', new Panel());

// Components interact ONLY through the mediator
const nameInput = dialog.components.get('nameInput');
const emailInput = dialog.components.get('emailInput');
const terms = dialog.components.get('termsCheckbox');
const submitBtn = dialog.components.get('submitButton');

console.log('\n--- User fills in form ---');
nameInput.setValue('John Doe');      // Mediator updates preview
emailInput.setValue('john@doe.com'); // Mediator enables submit (if valid)
terms.toggle();                       // Mediator manages submit state

console.log('\n--- User submits ---');
submitBtn.click();                   // Mediator collects and submits all data

Mediator vs Observer

+------------------------------------------------------------------+
|                                                                    |
|  MEDIATOR                             OBSERVER                    |
|                                                                    |
|  - Central coordinator               - Publisher broadcasts        |
|  - Knows about all colleagues        - Subject doesn't know       |
|  - Encapsulates complex interactions   concrete observers         |
|  - Bidirectional communication       - Unidirectional (notify)    |
|  - Reduces N-to-N to 1-to-N         - 1-to-N notification        |
|  - Mediator has logic                - No central logic            |
|                                                                    |
|  "Smart hub"                         "Event broadcast"            |
|                                                                    |
|  Risk: Mediator becomes a            Risk: No central control,    |
|  "God object" if too complex         hard to trace event flow     |
|                                                                    |
|  Example: Chat room                  Example: EventEmitter        |
|  (room manages who sees what)        (publisher doesn't care      |
|                                       who's listening)            |
|                                                                    |
+------------------------------------------------------------------+

When to Use Mediator

ScenarioWhy Mediator Helps
Form dialogsFields, buttons, labels need coordinated behavior
Chat roomsUsers communicate through a central hub
Air traffic controlPlanes coordinate through the tower
Game entitiesCharacters interact through game engine
Microservice orchestrationServices communicate through an orchestrator
UI state managementRedux/Vuex acts as a mediator between components

Key Takeaways

  1. Mediator reduces many-to-many to one-to-many -- objects only know the mediator, not each other
  2. Components become reusable because they're decoupled from specific partners
  3. Trade-off: the mediator can become a God Object if it accumulates too much logic -- keep it focused
  4. Air traffic control is the perfect analogy -- planes don't talk to each other, only to the tower
  5. Redux/Vuex ARE mediators -- components dispatch actions to a central store that notifies subscribers
  6. Mediator is bidirectional, while Observer is unidirectional -- mediator both receives and sends

Explain-It Challenge

You're building a smart home system where devices need to interact: when the doorbell rings, the camera starts recording, the porch light turns on, the TV pauses, and a notification is sent to your phone. If the security alarm is armed, the behavior changes. Design this using the Mediator pattern. How would you handle: (a) adding new devices without changing existing ones, (b) different "scenes" (home, away, night) changing the mediator's behavior, (c) a device failing mid-sequence?