Chain of responsibility
Chain of responsibility passes a request along a line of handlers, each of which can deal with it or pass it on. This page turns Pedal's unlock checks into a chain each city assembles for itself, and shows why the order of the links matters.
1. The pain
Before a Pedal dock releases a bike, several things must be true: the rider's account is active, their card works, the dock is online, the bike isn't reported broken. The first unlock() held all of them as one long run of if statements, each returning a different refusal.
Then Paris required riders to be 18 or over. The age rule went into everyone's unlock() behind if city == "paris". Later someone noticed the card check, which calls the bank and takes a third of a second, ran before the dock check, which is instant, so offline docks still cost a bank call each.
2. The idea in one picture
Chain of responsibility passes a request along a line of handler objects. Each handler either deals with it (here, by refusing with a reason) or passes it to the next one. The sender only talks to the first handler. Because the chain is built from separate objects, each city can assemble its own: Paris inserts an AgeCheck, and the order can be tuned so that cheap checks run before expensive ones.
There are two common flavours. In this page's, every handler can stop the request and it succeeds only if it reaches the end. In the other, the first handler able to handle the request does, and the rest never see it (support tickets escalating from bot to agent to engineer).
3. The roles
| Role | Its job | In Pedal |
|---|---|---|
| Handler | The interface: handle a request or pass it on; holds the next handler. | Check with handle() and then() |
| Concrete handler | One check. | AccountCheck, PaymentCheck, DockCheck, BikeCheck, AgeCheck |
| Client | Builds the chain and sends requests to its first handler. | The unlock service, with one chain per city |
4. Before and after
The unlock checks both ways (Python sketch; the full program is in section 5).
Without the pattern
def unlock(rider, dock, city):
if not rider.active:
return "refused: account suspended"
if city == "paris" and rider.age < 18:
return "refused: under 18"
if not bank.card_ok(rider): # 300 ms
return "refused: card expired"
if not dock.online: # instant
return "refused: dock offline"
if dock.bike_fault:
return f"refused: {dock.bike_fault}"
return "unlock allowed"With it
london = AccountCheck()
london.then(DockCheck()).then(BikeCheck()) \
.then(PaymentCheck())
paris = AccountCheck()
paris.then(AgeCheck()).then(DockCheck()) \
.then(BikeCheck()).then(PaymentCheck())
london.handle(request) or "unlock allowed"| The next change | Without the pattern | With it |
|---|---|---|
| Paris needs an age check | if city == "paris" inside everyone's unlock | Paris's chain gets an AgeCheck link |
| Run the bank call last | Move lines inside a long function and retest it | Reorder the links when building the chain |
| Test the dock check | Set up a rider, a card and a city first | Call DockCheck().handle() on its own |
Each rule is a small object, and the set and order of rules is configuration rather than code.
5. The code
Five small checks, chained with then(). London's chain refuses suspended accounts, expired cards, offline docks and broken bikes; Paris's chain adds an age check without changing any of them.
from __future__ import annotations
from dataclasses import dataclass
RIDERS = {"mia": ("active", "ok", 30), "zoe": ("suspended", "ok", 41), "leo": ("active", "expired", 25), "ana": ("active", "ok", 35), "sam": ("active", "ok", 16)}
DOCKS = {"D7": ("online", "ok"), "D9": ("offline", "ok"), "D5": ("online", "flat tyre"), "P2": ("online", "ok")}
@dataclass
class UnlockRequest:
rider: str
dock: str
# ---- Each handler checks one thing: refuse, or pass the request to the next handler. ----
class Check:
name = ""
def __init__(self) -> None:
self.next: Check | None = None
def then(self, nxt: Check) -> Check:
self.next = nxt
return nxt # so a chain can be built in one line
def handle(self, req: UnlockRequest) -> str | None:
problem = self.problem(req)
if problem:
return f"refused by {self.name} check: {problem}"
return self.next.handle(req) if self.next else None
def problem(self, req: UnlockRequest) -> str | None:
raise NotImplementedError
class AccountCheck(Check):
name = "account"
def problem(self, req: UnlockRequest) -> str | None:
return "account suspended" if RIDERS[req.rider][0] != "active" else None
class PaymentCheck(Check):
name = "payment"
def problem(self, req: UnlockRequest) -> str | None:
return "card expired" if RIDERS[req.rider][1] != "ok" else None
class AgeCheck(Check):
name = "age"
def problem(self, req: UnlockRequest) -> str | None:
return "riders must be 18 or over" if RIDERS[req.rider][2] < 18 else None
class DockCheck(Check):
name = "dock"
def problem(self, req: UnlockRequest) -> str | None:
return "dock offline" if DOCKS[req.dock][0] != "online" else None
class BikeCheck(Check):
name = "bike"
def problem(self, req: UnlockRequest) -> str | None:
health = DOCKS[req.dock][1]
return health if health != "ok" else None
def unlock(chain: Check, req: UnlockRequest) -> str:
return chain.handle(req) or "unlock allowed"
if __name__ == "__main__":
london = AccountCheck()
london.then(DockCheck()).then(BikeCheck()).then(PaymentCheck()) # cheap checks first, the bank call last
for rider, dock in [("mia", "D7"), ("zoe", "D7"), ("leo", "D7"), ("mia", "D9"), ("ana", "D5")]:
print(f"{rider} at {dock}: {unlock(london, UnlockRequest(rider, dock))}")
paris = AccountCheck() # Paris adds an age check; no handler changes
paris.then(AgeCheck()).then(DockCheck()).then(BikeCheck()).then(PaymentCheck())
print(f"sam at P2 (Paris): {unlock(paris, UnlockRequest('sam', 'P2'))}")
print(f"sam at D7 (London): {unlock(london, UnlockRequest('sam', 'D7'))}")
const RIDERS: Record<string, [string, string, number]> = { mia: ["active", "ok", 30], zoe: ["suspended", "ok", 41], leo: ["active", "expired", 25], ana: ["active", "ok", 35], sam: ["active", "ok", 16] };
const DOCKS: Record<string, [string, string]> = { D7: ["online", "ok"], D9: ["offline", "ok"], D5: ["online", "flat tyre"], P2: ["online", "ok"] };
interface UnlockRequest { rider: string; dock: string; }
// ---- Each handler checks one thing: refuse, or pass the request to the next handler. ----
abstract class Check {
abstract readonly name: string;
private next: Check | null = null;
then(nxt: Check): Check { this.next = nxt; return nxt; } // so a chain can be built in one line
handle(req: UnlockRequest): string | null {
const problem = this.problem(req);
if (problem) return `refused by ${this.name} check: ${problem}`;
return this.next ? this.next.handle(req) : null;
}
protected abstract problem(req: UnlockRequest): string | null;
}
class AccountCheck extends Check {
readonly name = "account";
protected problem(req: UnlockRequest) { return RIDERS[req.rider][0] !== "active" ? "account suspended" : null; }
}
class PaymentCheck extends Check {
readonly name = "payment";
protected problem(req: UnlockRequest) { return RIDERS[req.rider][1] !== "ok" ? "card expired" : null; }
}
class AgeCheck extends Check {
readonly name = "age";
protected problem(req: UnlockRequest) { return RIDERS[req.rider][2] < 18 ? "riders must be 18 or over" : null; }
}
class DockCheck extends Check {
readonly name = "dock";
protected problem(req: UnlockRequest) { return DOCKS[req.dock][0] !== "online" ? "dock offline" : null; }
}
class BikeCheck extends Check {
readonly name = "bike";
protected problem(req: UnlockRequest) { const h = DOCKS[req.dock][1]; return h !== "ok" ? h : null; }
}
const unlock = (chain: Check, req: UnlockRequest): string => chain.handle(req) ?? "unlock allowed";
const london = new AccountCheck();
london.then(new DockCheck()).then(new BikeCheck()).then(new PaymentCheck()); // cheap checks first, the bank call last
for (const [rider, dock] of [["mia", "D7"], ["zoe", "D7"], ["leo", "D7"], ["mia", "D9"], ["ana", "D5"]]) console.log(`${rider} at ${dock}: ${unlock(london, { rider, dock })}`);
const paris = new AccountCheck(); // Paris adds an age check; no handler changes
paris.then(new AgeCheck()).then(new DockCheck()).then(new BikeCheck()).then(new PaymentCheck());
console.log(`sam at P2 (Paris): ${unlock(paris, { rider: "sam", dock: "P2" })}`);
console.log(`sam at D7 (London): ${unlock(london, { rider: "sam", dock: "D7" })}`);import java.util.Map;
public class Main {
record Rider(String status, String card, int age) {}
record Dock(String link, String bike) {}
static final Map<String, Rider> RIDERS = Map.of("mia", new Rider("active", "ok", 30), "zoe", new Rider("suspended", "ok", 41),
"leo", new Rider("active", "expired", 25), "ana", new Rider("active", "ok", 35), "sam", new Rider("active", "ok", 16));
static final Map<String, Dock> DOCKS = Map.of("D7", new Dock("online", "ok"), "D9", new Dock("offline", "ok"), "D5", new Dock("online", "flat tyre"), "P2", new Dock("online", "ok"));
record UnlockRequest(String rider, String dock) {}
// ---- Each handler checks one thing: refuse, or pass the request to the next handler. ----
abstract static class Check {
private Check next;
abstract String name();
abstract String problem(UnlockRequest req); // null when this check is happy
Check then(Check nxt) { this.next = nxt; return nxt; } // so a chain can be built in one line
String handle(UnlockRequest req) {
String problem = problem(req);
if (problem != null) return "refused by " + name() + " check: " + problem;
return next != null ? next.handle(req) : null;
}
}
static class AccountCheck extends Check {
String name() { return "account"; }
String problem(UnlockRequest r) { return RIDERS.get(r.rider()).status().equals("active") ? null : "account suspended"; }
}
static class PaymentCheck extends Check {
String name() { return "payment"; }
String problem(UnlockRequest r) { return RIDERS.get(r.rider()).card().equals("ok") ? null : "card expired"; }
}
static class AgeCheck extends Check {
String name() { return "age"; }
String problem(UnlockRequest r) { return RIDERS.get(r.rider()).age() < 18 ? "riders must be 18 or over" : null; }
}
static class DockCheck extends Check {
String name() { return "dock"; }
String problem(UnlockRequest r) { return DOCKS.get(r.dock()).link().equals("online") ? null : "dock offline"; }
}
static class BikeCheck extends Check {
String name() { return "bike"; }
String problem(UnlockRequest r) { String h = DOCKS.get(r.dock()).bike(); return h.equals("ok") ? null : h; }
}
static String unlock(Check chain, UnlockRequest req) {
String result = chain.handle(req);
return result != null ? result : "unlock allowed";
}
public static void main(String[] args) {
Check london = new AccountCheck();
london.then(new DockCheck()).then(new BikeCheck()).then(new PaymentCheck()); // cheap checks first, the bank call last
String[][] asks = {{"mia", "D7"}, {"zoe", "D7"}, {"leo", "D7"}, {"mia", "D9"}, {"ana", "D5"}};
for (String[] a : asks) System.out.println(a[0] + " at " + a[1] + ": " + unlock(london, new UnlockRequest(a[0], a[1])));
Check paris = new AccountCheck(); // Paris adds an age check; no handler changes
paris.then(new AgeCheck()).then(new DockCheck()).then(new BikeCheck()).then(new PaymentCheck());
System.out.println("sam at P2 (Paris): " + unlock(paris, new UnlockRequest("sam", "P2")));
System.out.println("sam at D7 (London): " + unlock(london, new UnlockRequest("sam", "D7")));
}
}Output (the same in all three languages)
mia at D7: unlock allowed zoe at D7: refused by account check: account suspended leo at D7: refused by payment check: card expired mia at D9: refused by dock check: dock offline ana at D5: refused by bike check: flat tyre sam at P2 (Paris): refused by age check: riders must be 18 or over sam at D7 (London): unlock allowed
6. Try it
Order the checks
Typical unlock requests: 2% fail the account check, 4% the dock check, 6% the bike check, 3% the card check. The card check calls the bank and takes 300 ms; the others take a few. Reorder the chain, or switch a check off.
Expected cost: each check runs only for requests that passed every check before it.
7. When not to use it
What you get
- Each rule is separate: small, testable, reusable.
- Configurable: each city builds its own chain.
- Order is tunable: cheap or likely-to-fail checks first.
- The sender is simple: it knows only the first handler.
What you pay
- No guarantee of handling: a misbuilt chain can let a request through unchecked.
- Harder to see the whole rule: it's spread across objects and the code that builds the chain.
- Debugging hops: which link stopped it? Log the handler name, as the refusals here do.
- Long chains add latency: every link is a call.
| If… | Use instead |
|---|---|
| Every handler must see the request, in any order | 18 Observer |
| Each step transforms the data for the next | A pipeline (the Styles track's pipes and filters) |
| You add behaviour around one call rather than decide on it | 07 Decorator |
| There are two fixed checks | Two if statements |
8. How it goes wrong
BikeCheck assumes DockCheck already ran, say so or check it; reordering will break it.9. Remember it and say it
How this hook works: what “Airport checkpoints” means
What the slogan means, phrase by phrase
- “Checkpoints”
- Handler objects.
- “stops you or waves you on”
- Refuse, or pass to the next.
- “routes can add stops”
- Each city builds its own chain.
Why this picture fits: Passengers pass a line of independent checks; none needs to know the others' rules.
The story → the concept
| The airport | The code |
|---|---|
| Check-in, security, passports, gate | AccountCheck, DockCheck, BikeCheck, PaymentCheck |
| Extra check on some routes | Paris's AgeCheck |
| Being turned back | "refused by payment check: card expired" |
| Boarding | "unlock allowed" |
Watch it on an example
- Leo asks to unlock at D7.
- The account check passes him on; the payment check finds his card expired.
- The chain stops there: dock and bike checks never run.
The trap it saves you from
One long function of conditions with city-specific ifs mixed in.
It still works when…
Yes: web middleware, logging handlers, approval workflows and support escalation all use chains.
In an interview or code review
- Say why a chain: "Unlocking needs four independent checks, and cities add their own, so each is a handler and each city builds a chain."
- Mention order: cheap and likely-to-fail checks first; the bank call last.
- Name the end condition: reaching the end means allowed, so a missing link is a security question.
- Real examples: HTTP middleware, servlet filters, Python's logging handlers.
10. Check yourself
What does each handler do with a request?
Leo's card is expired. Which checks run after the payment check?
Paris needs an age check. What changes?
Why put the bank call last?
Chain vs observer?
11. Go deeper
| Resource | Why |
|---|---|
| Architecture styles 11: Pipes and filters | Steps that transform rather than decide. |
| Python logging handlers | Records passed along handlers and filters. |
Java Servlet FilterChain | A chain in every Java web server. |
| Wikipedia: Chain of responsibility | Variants and examples. |