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):
// 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:
| Decision | Meaning | What happens |
|---|---|---|
| pass → next | Not mine | Keep the claim unchanged and let the next desk inspect it. |
| claimed → stop | I can take it | Return the first desk that matches this claim. |
| blocked → stop | Do not continue | This 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.
// 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); | Role | Responsibility | What 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. |
| Dispatcher | Offers 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.
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
const day = [central, security, terminal, airline]; 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.
// 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.
| Change | Simple branch | Ordered handlers | Cost to keep |
|---|---|---|---|
| Add a permanent desk | Add another condition to intake. | Add a handler to the configured list. | Someone still owns and reviews the order. |
| Temporarily skip a desk | Add 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 open | Check 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 informed | Call 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?
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.