← Design patterns
Behavior Coordination between collaborators

Mediator

Give collaboration a home.

A call-in desk has two outputs: a host cue and an audio board. When a caller is ready, both should identify the same person. A Mediator gives that collaboration rule one owner, so the screener, the host cue and the audio board can each keep their own job.

TypeScriptGoRust The same desk in each language.

Definition

A mediator is an object that encapsulates how a set of objects interact, so they no longer refer to each other directly and their interaction can change on its own.

After Gamma et al., Design Patterns (1994), Mediator

01Recognize the situation

The host cue and the audio board must agree about the caller.

A community radio station takes calls on air. A screener answers the phones and clears callers, a host cue tells the host who is on, and an audio board opens the caller’s line to the studio. When a caller is ready, both the cue and the board should identify the same person.

For a single participant, the screener could call the host directly and then update the audio board. That is easy to follow while nothing else can invalidate the request. Here is that first version, a fragment: the screener holds both panels and calls them itself (HostCue and AudioBoard are the panels from the program in the next section).

screener.ts
// The screener cues the host and opens the line itself.
class Screener {
  host: HostCue;
  board: AudioBoard;
  constructor(host: HostCue, board: AudioBoard) {
    this.host = host;
    this.board = board;
  }
  clear(callerId: string) {
    this.host.show(callerId);
    this.board.open(callerId);
  }
}

Now a cleared caller may belong to a segment that just ended, or the screener may release them before a queued request arrives. A line can fail as it opens, and the host must not be cued for a caller with no line, so the board needs the host cue. The producer moves to the next segment, which ends the call, so the producer needs both panels. Each part now holds the others it affects, and the rule about how they work together is spread across all of them. If each output reacts independently, one can show a caller while the other still shows the previous segment. The desk needs one place to check the caller’s status and segment before sending either update.

A Mediator coordinates how participants work together. The participants report what happened to one object and never call each other; that object holds the rule and tells each participant what to do. Here it is the call-in desk.

02Give the rule one owner

Panels report to the desk. The desk tells the others.

Each panel knows one thing besides its own job: where to report. The screener and the board hold the desk as a Desk and call notify. The desk holds the panels and decides: when the screener clears a caller, it cues the host and opens the line; when the board reports a failed line, it clears the cue if the cue still shows that caller. This is the whole program the lab runs: the three panels, the desk, and two callers, the second of whom has the faulty line. It leaves segments and released callers out; the production desk, after the experiment, adds them.

desk.ts
// What a panel can tell the desk.
type DeskEvent = { kind: 'cleared' | 'line-failed'; callerId: string };

// The one thing every panel knows: where to report.
interface Desk {
  notify(event: DeskEvent): void;
}

// Shows the host who is on air.
class HostCue {
  shown = 'none';
  show(callerId: string) {
    this.shown = callerId;
  }
}

// Opens a caller's phone line to the studio. One line is faulty.
class AudioBoard {
  line = 'none';
  desk: Desk;
  faulty: string;
  constructor(desk: Desk, faulty: string) {
    this.desk = desk;
    this.faulty = faulty;
  }
  open(callerId: string) {
    if (callerId === this.faulty) {
      this.line = 'none';
      this.desk.notify({ kind: 'line-failed', callerId });
      return;
    }
    this.line = callerId;
  }
}

// Clears a caller to go on air.
class Screener {
  desk: Desk;
  constructor(desk: Desk) {
    this.desk = desk;
  }
  clear(callerId: string) {
    this.desk.notify({ kind: 'cleared', callerId });
  }
}

// The mediator: the only place that knows how the panels work together.
class CallInDesk implements Desk {
  host = new HostCue();
  board: AudioBoard;
  constructor(faulty: string) {
    this.board = new AudioBoard(this, faulty);
  }
  notify(event: DeskEvent) {
    if (event.kind === 'cleared') {
      this.host.show(event.callerId);
      this.board.open(event.callerId);
    } else if (this.host.shown === event.callerId) {
      this.host.show('none');
    }
  }
}

const desk = new CallInDesk('caller-22');
const screener = new Screener(desk);

for (const callerId of ['caller-17', 'caller-22']) {
  screener.clear(callerId);
  const cue = desk.host.shown;
  const line = desk.board.line;
  const agree = cue === line;
  console.log(`${callerId}: cue=${cue} line=${line} agree=${agree}`);
}
Who owns what
RoleResponsibilityWhat it handles
ScreenerProvide a cleared caller.Knows the desk and nothing else. It does not know which panels a cleared caller affects.
Host cue, audio boardShow the same accepted caller.The cue shows a name; the board opens a line, and reports to the desk when one fails. Neither knows the other exists.
DeskCheck one collaboration rule.Decides what a cleared caller means for each panel, and what a failed line undoes. The only code that knows how the panels work together.

Neither panel changes when the rule does. Moving the failure rule, adding a panel or rejecting a caller is an edit to the desk alone.

03Predict a report mid-handling

A panel reports while the desk is still deciding.

Experiment

Three panels, one desk

desk.ts
this.board.open(event.callerId);
this.host.show(event.callerId);
Suppose lines 56–57 change as above, so the desk opens the line before it cues the host. caller-22’s line is the faulty one. After the screener clears caller-22, what does the host cue show?
What a production desk addsCurrent state, rejected requests, one event at a time

The same desk, with the rules a real call-in needs. It keeps the active segment and the callers the screener has released, and a cleared caller carries the segment the request was prepared for. Advancing a segment or releasing a caller changes coordinator state. A request that captured the earlier segment is still just a request; the coordinator checks it against current state when it arrives. A rejection changes neither output. Advancing the segment, or releasing the caller on air, ends the call on both panels. The desk keeps a log of what it decided, and its snapshot is a copy.

And it handles one event at a time. A report that arrives while the desk is still handling another waits in a queue until that handler has finished, so the order of the lines inside a handler no longer decides the outcome: the production desk opens the line before it cues the host, the order the prediction asked about, and the failure still clears the cue. Its fields are #private, so a panel holding the desk can call only notify.

desk.ts
/** What the panels, the screener and the producer can tell the desk. */
export type DeskEvent =
  | { kind: 'cleared'; callerId: string; segment: string }
  | { kind: 'released'; callerId: string }
  | { kind: 'advanced'; segment: string }
  | { kind: 'line-failed'; callerId: string };

export interface Desk {
  notify(event: DeskEvent): void;
}

export class HostCue {
  shown = 'none';
  show(callerId: string) {
    this.shown = callerId;
  }
}

export class AudioBoard {
  line = 'none';
  #desk: Desk;
  #faulty: string;
  constructor(desk: Desk, faulty: string) {
    this.#desk = desk;
    this.#faulty = faulty;
  }
  open(callerId: string) {
    if (callerId === this.#faulty) {
      this.line = 'none';
      this.#desk.notify({ kind: 'line-failed', callerId });
      return;
    }
    this.line = callerId;
  }
  close() {
    this.line = 'none';
  }
}

export type DeskSnapshot = Readonly<{
  segment: string;
  cue: string;
  line: string;
  log: readonly string[];
}>;

/**
 * The call-in desk: the only place that knows how the panels work together.
 * It checks every request against the current segment and caller status
 * before either panel changes, and it handles one event at a time.
 */
export class CallInDesk implements Desk {
  readonly host = new HostCue();
  readonly board: AudioBoard;
  #segment: string;
  #released = new Set<string>();
  #queue: DeskEvent[] = [];
  #handling = false;
  #log: string[] = [];

  constructor(segment: string, faulty: string) {
    this.#segment = segment;
    this.board = new AudioBoard(this, faulty);
  }

  notify(event: DeskEvent) {
    // A panel may report while the desk is still handling another event:
    // it waits, so each handler finishes before the next one starts.
    this.#queue.push(event);
    if (this.#handling) return;
    this.#handling = true;
    try {
      for (let next = this.#queue.shift(); next; next = this.#queue.shift()) {
        this.#handle(next);
      }
    } finally {
      this.#handling = false;
    }
  }

  #handle(event: DeskEvent) {
    switch (event.kind) {
      case 'cleared': {
        const { callerId, segment } = event;
        if (this.#released.has(callerId)) {
          this.#log.push(`${callerId} rejected: released`);
        } else if (segment !== this.#segment) {
          this.#log.push(`${callerId} rejected: stale segment`);
        } else {
          this.#log.push(`${callerId} on air`);
          this.board.open(callerId);
          this.host.show(callerId);
        }
        break;
      }
      case 'released':
        this.#released.add(event.callerId);
        if (this.host.shown === event.callerId) this.#endCall();
        this.#log.push(`${event.callerId} released`);
        break;
      case 'advanced':
        this.#segment = event.segment;
        this.#endCall();
        this.#log.push(`segment ${event.segment}`);
        break;
      case 'line-failed':
        if (this.host.shown === event.callerId) this.host.show('none');
        this.#log.push(`${event.callerId} line failed`);
        break;
    }
  }

  #endCall() {
    this.host.show('none');
    this.board.close();
  }

  snapshot(): DeskSnapshot {
    return {
      segment: this.#segment,
      cue: this.host.shown,
      line: this.board.line,
      log: [...this.#log]
    };
  }
}
Reading the TypeScriptStructural interfaces and a desk that hands itself out

CallInDesk satisfies Desk because it has a matching notify; implements Desk only asks the compiler to check that. The board stores the desk as a Desk, so a test can hand it a fake one.

The desk passes this to the board in its constructor, before the desk has finished constructing. That is safe here because the board only stores it; a panel that called notify from its own constructor would reach a half-built desk. The kind field is a union of string literals, so a misspelled event does not compile.

04Meet it in your language

You already route things through one owner.

The book’s own example of a mediator is a dialog box, and browser forms are where you meet it. On a checkout form, choosing a country changes which postcodes are valid and clears the field, and the shipping button is enabled only for a valid postcode. The first version wires each control to the next. The bug readers meet: setting postcode.value from script fires no input event, because browsers fire those for the user’s edits, so the shipping button stays enabled with an empty postcode. The fix makes the form the mediator: it hears every control (their events bubble to it), and one update decides for all of them.

checkout.ts
const form = document.querySelector<HTMLFormElement>('#checkout')!;
const country = form.querySelector<HTMLSelectElement>('#country')!;
const postcode = form.querySelector<HTMLInputElement>('#postcode')!;
const shipping = form.querySelector<HTMLButtonElement>('#shipping')!;
const postcodes: Record<string, string> = { US: '\\d{5}', NL: '\\d{4} ?[A-Z]{2}' };

// Before: each control updates the next one itself.
function wireBefore() {
  country.addEventListener('change', () => {
    postcode.pattern = postcodes[country.value] ?? '';
    postcode.value = ''; // fires no input event
  });
  postcode.addEventListener('input', () => {
    shipping.disabled = postcode.value === '' || !postcode.validity.valid;
  });
}

// After: the form hears every control and decides for all of them.
function wire() {
  form.addEventListener('change', (event) => {
    if (event.target === country) {
      postcode.pattern = postcodes[country.value] ?? '';
      postcode.value = '';
    }
    update();
  });
  form.addEventListener('input', update);
}

function update() {
  shipping.disabled = postcode.value === '' || !postcode.validity.valid;
}

export { wire, wireBefore };

Keep each control’s listener to reporting, and the rule in one function. If one control must react to another’s change made from script, call that function; dispatching a synthetic input event instead runs every listener again, mid-update. Components meet the same idea: React’s sharing-state example moves related state to a common parent and passes values and handlers to its children. The same boundary appears here: inputs report intent to one owner, which supplies each participant only what it needs.

05Compare the direct call

Coordinate when a rule spans participants.

Suppose the host cue is the only output, and one synchronous handler can validate and update it. A direct call is easy to understand. The coordinator earns its place when eligibility and segment rules must govern multiple participants together. Give the code a plausible change and follow what has to change with it.

What changes when another requirement arrives?
The changeParticipants calling each otherOne desk
A website “now on air” panel joinsEvery participant that changes the caller on air has to learn to update it.The desk tells one more panel. The others do not change.
A late request must change neither panelEach sender checks before it calls, and the one that forgets leaves the panels disagreeing.The desk checks once, against its current state, before either panel changes.
One button changes one output, and nothing depends on itOne call, in one place: the clearest version.An interface, a notify method and an event, for one line of behaviour.
The desk gathers every rule in the studioRules stay beside the participants they concern.The desk becomes the class everyone edits. Split it: one mediator per collaboration.

The pattern leaves decisions open. Does the mediator handle a report at once, or after the current event? The experiment shows what at once costs. Who owns the participants, and can they exist without it? And what goes where: a rule about how participants work together belongs in the mediator; what one participant does on its own (how the cue draws a name, how the board dials) stays in that participant. A mediator that starts doing the participants’ jobs is the “god object” its critics warn about.

Choose direct calls when one request has one clear owner and little shared policy. Choose a coordinator when several participants must follow the same acceptance rule. Its cost is a new owner that must stay focused on one workflow.

When delivery is asynchronousThe coordinator still needs a freshness rule

A real asynchronous workflow should carry a revision or generation with the request, then compare it with current state at the point of handling. The production desk’s cleared caller carries the segment it was prepared for for that reason; it does not provide a general concurrency or transactional guarantee.

Its neighbours: here, the Mediator checks one acceptance rule before writing to both outputs. Observer instead distributes notifications to subscribers, and lets each decide what to do; the two are often combined, with participants notifying the mediator as observers would. Adapter translates between an interface and a provider’s shape. Facade gives callers one entry point to a subsystem whose parts do not know it exists; a mediator’s participants talk to it, and it talks back.

06Make the call

Who knows the rule, and who only reports?

A code editor has a file tree, tabs and an outline. When a file is deleted from the tree, its tab must first ask about unsaved changes; then the tab closes and the outline empties, or, if the user cancels, nothing changes. How should the views coordinate?
A podcast studio app has a record button that starts the recorder. Nothing else reacts to it, and no rule involves another part of the app. How should the button reach the recorder?

Before you open the answer below, describe the coordinator without its pattern name: what arrives, which current facts it checks, and which participants receive the accepted value. Then say what goes wrong when a participant reports while the desk is still deciding.

One way to say itWho reports, who decides, and when

The screener reports that a caller is cleared, and the board reports that a line failed; neither knows the other panels. The desk receives every report, checks it against what it currently knows (the segment, the callers it has released, who is on the cue) and tells each panel what to show, so the cue and the board can only change together, by one rule. When a panel reports in the middle of the desk’s own handling, a desk that handles it at once runs the second rule inside the first, and whatever the first does afterwards wins: the cue named a caller whose line had already failed. Handling one event at a time, with a queue, makes the order safe. Reach for a mediator when a rule spans participants; keep a direct call when one request has one clear owner.

Practice in the lab

A video-call app’s window has a camera, a screen-share button and a status banner, and later a network monitor and a recorder. Like the lesson’s panels and their desk, each control should report to the window, and the window decides what the others show. Each level starts from the previous level’s finished code, so you can begin at any of them.

  • MEDIATOR-101 (easy) The host stopped my share and my camera stayed hidden
  • MEDIATOR-102 (medium) A poor connection should hide the camera, and only for a while Pro
  • MEDIATOR-103 (hard) Review: the recording banner vanishes and the camera stays off Pro