← Design patterns
Behavior Ordered handlers and explicit stopping

Chain of responsibility

Give each eligible handler a chance to decide.

A camera pouch has gone missing. In this fictional airport, its claim can reach a security desk, the terminal desk, the airline-custody desk, then the central store. The useful question is who gets to decide first, and what the rest of the chain should do with that decision.

TypeScriptGoRust The same chain in each language.

Definition

A chain of responsibility passes a request along a chain of handlers, each of which either handles it or hands it to the next, so the sender is not coupled to the one receiver that handles it.

After Gamma et al., Design Patterns (1994), Chain of Responsibility

01Recognize the situation

A fixed set of desks can start with a simple branch.

This airport and its lost-property process are fictional. A claim names the item and where it was last seen. Security keeps what it confiscated at screening; the terminal desk and the airline-custody desk each hold what was found in their own area; the central store takes everything else. A short branch can check them in that order (a fragment: the claim’s type is the one the next section’s program defines):

route.ts
// Every desk's rule, in the order intake checks them.
function routeClaim(claim: Claim): string {
  if (claim.seenAt === 'screening') return 'blocked by security';
  if (claim.seenAt === 'terminal') return 'claimed by terminal';
  if (claim.seenAt === 'airline') return 'claimed by airline';
  return 'claimed by central';
}

With four fixed desks, that sequence is easy to read and may be enough. Now the central store closes at night, so a claim that nobody else matches must wait for the morning instead of being claimed. Another terminal opens with its own security desk. The airline desk starts holding only its own airline’s items. Each change is another condition in the one function, and intake now knows every desk’s rule and every desk’s schedule.

The intake owner can instead offer the same claim to an ordered list and let each desk apply its own rule.

Chain of responsibility moves the decision about whether to act into handlers and gives an owner control over their order. A handler that passes leaves the request available for the next one. A claim or a block is terminal under the rule we are teaching.

02Pass, claim or block

Three decisions, one request, and a known stop.

The claim carries its case ID, item description and where it was last seen. The dispatcher records which desks it visited. Each desk returns one of three decisions:

A desk’s three decisions
DecisionMeaningWhat happens
pass → nextNot mineKeep the claim unchanged and let the next desk inspect it.
claimed → stopI can take itReturn the first desk that matches this claim.
blocked → stopDo not continueThis request must not be handed farther down this chain.

If every desk passes, or the owner supplies an empty list, the result is unhandled. That is useful information: the caller can route it to a review queue instead of reporting a made-up match. This is the whole program the lab runs: the four desks, the dispatcher, the day and night chains the owner builds, and three claims.

claims.ts
// A lost-property claim, and what one desk decides about it.
type Claim = { caseId: string; item: string; seenAt: string };
type Decision = 'pass' | 'claimed' | 'blocked';
type Desk = { name: string; handle: (claim: Claim) => Decision };
type Outcome = { status: string; desk: string; checked: string[] };

// Security keeps what it confiscated at screening: those claims stop here.
const security: Desk = {
  name: 'security',
  handle: (claim) => (claim.seenAt === 'screening' ? 'blocked' : 'pass'),
};
const terminal: Desk = {
  name: 'terminal',
  handle: (claim) => (claim.seenAt === 'terminal' ? 'claimed' : 'pass'),
};
const airline: Desk = {
  name: 'airline',
  handle: (claim) => (claim.seenAt === 'airline' ? 'claimed' : 'pass'),
};
// The central store takes whatever reaches it.
const central: Desk = { name: 'central', handle: () => 'claimed' };

// Offer the claim to each desk in order; the first claim or block ends it.
function routeClaim(claim: Claim, desks: Desk[]): Outcome {
  const checked: string[] = [];
  for (const desk of desks) {
    const decision = desk.handle(claim);
    checked.push(desk.name);
    if (decision !== 'pass') {
      return { status: decision, desk: desk.name, checked };
    }
  }
  return { status: 'unhandled', desk: 'nobody', checked };
}

// Route one claim and print where it ended up.
function report(claim: Claim, desks: Desk[]) {
  const outcome = routeClaim(claim, desks);
  const where = `${outcome.status} by ${outcome.desk}`;
  const path = outcome.checked.join(' > ');
  console.log(`${claim.caseId} ${claim.item}: ${where}; checked ${path}`);
}

// The intake owner chooses which desks are open, and their order.
const day = [security, terminal, airline, central];
const night = [security, terminal, airline]; // the central store is closed

const pouch = { caseId: 'LP-202', item: 'camera pouch', seenAt: 'airline' };
const powerBank = { caseId: 'LP-203', item: 'power bank', seenAt: 'screening' };
const umbrella = { caseId: 'LP-205', item: 'umbrella', seenAt: 'gate 12' };
report(pouch, day);
report(powerBank, day);
report(umbrella, night);
Who owns what
RoleResponsibilityWhat it handles
Intake (the owner)Chooses the desks and their order.Knows which desks are open today, builds the day or night chain, and decides what to do with the outcome, unhandled included.
Desk (a handler)Owns one local rule.Reads the claim and returns pass, claimed or blocked. It knows nothing about the other desks or where it sits.
DispatcherOffers the claim in order.routeClaim asks each desk in turn, records who it checked, and stops at the first decision that is not a pass.
What a production chain addsA checked order, an intake rule, a claim no desk can change

The same desks and dispatcher, with a contract. The owner’s order comes from configuration, the day and night schedules, so chainOf rejects a desk name it does not know or one listed twice. A claim without a case ID is a thrown ChainError, and the outcome keeps the case ID.

Before any desk is asked, intake applies its own rule: a case reference that is already open is blocked, whichever desks are open tonight. A rule that must hold whatever the chain looks like belongs to the caller, not to a desk that might be closed. Every desk reads the same claim and none can change it for the next: each one receives a frozen copy, and the outcome is frozen too.

claims.ts
export type Claim = Readonly<{ caseId: string; item: string; seenAt: string }>;
export type Decision = 'pass' | 'claimed' | 'blocked';
export type Desk = Readonly<{ name: string; handle: (claim: Claim) => Decision }>;
export type Outcome = Readonly<{
  status: 'claimed' | 'blocked' | 'unhandled';
  desk: string;
  caseId: string;
  checked: readonly string[];
}>;

export class ChainError extends Error {
  override name = 'ChainError';
}

// Each desk owns one local rule and knows nothing about the others.
const deskByName: Readonly<Record<string, Desk>> = {
  security: {
    name: 'security',
    handle: (claim) => (claim.seenAt === 'screening' ? 'blocked' : 'pass'),
  },
  terminal: {
    name: 'terminal',
    handle: (claim) => (claim.seenAt === 'terminal' ? 'claimed' : 'pass'),
  },
  airline: {
    name: 'airline',
    handle: (claim) => (claim.seenAt === 'airline' ? 'claimed' : 'pass'),
  },
  central: { name: 'central', handle: () => 'claimed' },
};

// The owner's order, read from configuration: every name known, none twice.
export function chainOf(names: readonly string[]): Desk[] {
  const seen = new Set<string>();
  return names.map((name) => {
    if (!Object.hasOwn(deskByName, name)) {
      throw new ChainError(`unknown desk: ${name}`);
    }
    if (seen.has(name)) throw new ChainError(`desk listed twice: ${name}`);
    seen.add(name);
    return deskByName[name]!;
  });
}

export function routeClaim(
  claim: Claim,
  desks: readonly Desk[],
  openCases: ReadonlySet<string>
): Outcome {
  const { caseId } = claim;
  if (caseId.trim() === '') throw new ChainError('claim has no case id');
  // The intake owner's rule, whichever desks are open: one open case per
  // reference. No desk sees a duplicate.
  if (openCases.has(caseId)) {
    const checked: string[] = [];
    return Object.freeze({ status: 'blocked', desk: 'intake', caseId, checked });
  }
  // Every desk reads the same claim; none can change it for the next.
  const offered = Object.freeze({ ...claim });
  const checked: string[] = [];
  for (const desk of desks) {
    const decision = desk.handle(offered);
    checked.push(desk.name);
    if (decision !== 'pass') {
      const status = decision;
      return Object.freeze({ status, desk: desk.name, caseId, checked });
    }
  }
  return Object.freeze({ status: 'unhandled', desk: 'nobody', caseId, checked });
}

03Predict the reordered chain

Move one desk. Who decides now?

Experiment

Three claims, four desks

claims.ts
const day = [central, security, terminal, airline];
Suppose line 45 puts the central store first, as above, and nothing else changes. What happens to the power bank, LP-203, confiscated at screening?
Reading the TypeScriptObjects with a function, a string union, an early return

A Desk is a plain object with a name and a handle function, so the four desks need no class and no interface to implement; any object of that shape is a desk. Decision is a union of three strings, and the type checker rejects a fourth.

The dispatcher returns from inside the loop, so a claim or a block ends it on the spot; only a pass reaches the next iteration. The outcome holds the same checked array the loop filled. The book’s linked form would give each desk a next field and a method that calls it; here the order is the array the owner passes in.

04Meet it in your language

You already pass requests along.

In the browser, every click is offered to a chain: the element clicked, then each ancestor up to the document. That chain’s rule is the opposite of this lesson’s: a listener that acts does not stop the event, which keeps bubbling until a listener calls stopPropagation(). The bug readers meet is a Delete button inside a clickable order row. Click Delete and the order is deleted, then the click bubbles to the row, which opens the order it just deleted.

event.stopPropagation() in the button’s listener stops it, and also hides the click from every listener above, including a document-level one that closes open menus or records analytics. The fix below keeps the decision local instead: the row passes on a click that came from a control inside it.

rows.ts
// Two handlers on one click: the Delete button, and the order row around it.
declare function openOrder(id: string): void;
declare function deleteOrder(id: string): void;

// Before: a click on Delete deletes the order, then bubbles up to the row,
// which opens the order it just deleted.
export function wireRowBefore(row: HTMLElement, remove: HTMLButtonElement) {
  const id = row.dataset.id ?? '';
  remove.addEventListener('click', () => deleteOrder(id));
  row.addEventListener('click', () => openOrder(id));
}

// After: the row passes on a click that a control inside it already handled.
export function wireRow(row: HTMLElement, remove: HTMLButtonElement) {
  const id = row.dataset.id ?? '';
  remove.addEventListener('click', () => deleteOrder(id));
  row.addEventListener('click', (event) => {
    const target = event.target;
    if (target instanceof Element && target.closest('button')) return;
    openOrder(id);
  });
}

Read the contract of the chain you are in: the DOM continues by default and stops when told; the lesson’s dispatcher stops at the first decision by default. A listener that acts and should end the request has to say so.

05Choose who owns the order

Keep the branch until the order has a reason to vary.

For a few permanent desks with one stable priority, the branch is direct and easy to debug. An explicit chain earns its place when the owner needs to compose eligible handlers, change their priority, or stop on a distinct refusal without teaching the intake function every desk rule.

What changes when the claim workflow changes?
ChangeSimple branchOrdered handlersCost to keep
Add a permanent deskAdd another condition to intake.Add a handler to the configured list.Someone still owns and reviews the order.
Temporarily skip a deskAdd an availability check around its branch.Build the active chain without that desk.Schedules and cleanup still belong to the owner.
A rule must hold whichever desks are openCheck it at the top of intake.Check it in the caller, before dispatch, not in a desk.A desk can be closed, or passed over by the one before it.
Keep every desk informedCall them all directly.This first-decision chain is the wrong policy.Use an explicit broadcast when every subscriber should receive the request.

The chain does not decide who belongs in it, prevent duplicate registration, retry a failed handler, or resolve conflicting claims. The owner still sets those policies and handles operational failures. A handler that claims everything, like the central store, belongs last; a handler that must be able to refuse belongs before anything that could claim first.

Middleware: a related chain with a different promise

Express middleware is also arranged in request order, and so are Go’s wrapped handlers and Rust’s tower layers. A middleware function can finish a response, pass control with next(), or pass an error to error-handling middleware. The policy is broader than first match: middleware can do work before calling next(), and work after it returns. The pattern name alone does not tell you whether a chain stops at the first handler that acts. Read the contract the caller actually configured.

Its neighbours: Decorator wraps an object and always calls the one inside, while a handler in a chain may stop the request. Observer delivers an event to every subscriber, which is the broadcast this chain is not. Command turns the request itself into an object, which is often what travels along a chain. Strategy picks one policy before the call; a chain lets each candidate decide during it. And Mediator is the next step when colleagues need to coordinate through a shared interaction boundary rather than hand a request along.

06Make the call

Pass, or stop the request?

A checkout tries payment processors in the order the shop configured. Processor A is down for maintenance. Processor B declines the card as suspected fraud. What should B’s handler return?
Three desks that never close, in a priority nobody has changed in years, each with a one-line rule. A pull request replaces the branch with a Desk type, a dispatcher and a configured chain. What do you say in review?

Before you open the answer below, try saying the rule without the pattern name: who chooses the eligible handlers and their order, what each handler may decide, and what the caller should do when every handler passes?

One way to say itWho owns the order, what stops it, what nobody claimed

The owner chooses which desks are eligible and in what order; each desk can pass, claim, or block, and the first decision that is not a pass ends the request. Each desk owns only its own rule and reads the same claim, so a desk can be added, closed or moved without touching the others, and because the first decision wins, the order is policy: whatever claims everything goes last, and whatever must be able to refuse goes before anything that could claim. A rule that must hold whichever desks are open belongs to the caller, before dispatch, not to one desk. When every desk passes, the result is unhandled, and that is information: the caller routes it somewhere a person will see it, instead of inventing a decision. And when the desks and their order never change, a branch says the same thing in one place.

Practice in the lab

A company’s expense tool offers each report to the submitting team’s approvers in order, as the lesson’s intake offers a claim to its desks: each approver approves up to their own limit or passes the report on, and the first decision ends it. Each team’s roster comes from the HR system and changes from week to week. Each level starts from the previous level’s finished code, so you can begin at any of them.

  • CHAIN-101 (easy) Expenses still go to Dana while she is on leave
  • CHAIN-102 (medium) Travel bookings skip the travel desk Pro
  • CHAIN-103 (hard) Review: duplicates are paid, and the CFO approves what she never saw Pro