Chain of Responsibility pattern
Open in the interactive app: practice, quiz, mock interview →In one minute
- Chain of Responsibility passes a request along an ordered list of handlers until one of them takes it. The sender knows only the head of the chain, not which handler will answer or whether any will.
- Each handler makes one local decision: "this is mine" (handle and stop) or "not mine" (pass it on). Adding a new rule is inserting a handler, not editing a growing
if/elifladder. - The order of the chain is the policy. "Reject blocked categories before any approver looks" and "the cheapest approver who can sign gets it" are both expressed purely by position.
- The defining question is what happens at the end of the chain. A request that falls off the end must produce an explicit outcome (escalate, reject, default), never silence.
- It is not the same as middleware. A decorator stack runs every layer around one core call; a chain stops at the first handler that claims the request. Agents blur the two, and review should ask which one the code means.
How it works
The problem
An expense tool routes each claim to someone who can approve it. The first version is one function:
def route(claim):
if claim.category in ("gambling", "gifts"):
return reject("blocked category")
if claim.amount <= 500_00 and claim.requester != "lead":
return approve("lead")
if claim.amount <= 5_000_00:
return approve("manager")
...Every new rule (a regional limit, an out-of-office approver, a fraud check) edits this function, and rules interact through an order nobody wrote down. When finance asks "who would have approved this claim last Tuesday?", the answer is buried in control flow.
One handler per rule
Chain of Responsibility splits the ladder into objects with one method:
class Approver:
def __init__(self, name, limit_cents):
self.name, self.limit = name, limit_cents
def decide(self, claim):
if claim.requester == self.name or claim.amount_cents > self.limit:
return None # not mine: pass it on
return ("approved", "within limit") # mine: stop hereA chain holds handlers in order and asks each in turn. The first non-empty answer wins. The rule set becomes data: [policy, lead, manager, director, cap]. Inserting a fraud check before the approvers is one line in the wiring; it does not touch any approver.
Two shapes: linked and list
The Gang of Four version links handlers: each holds a next pointer and calls next.handle(request) when it declines. A handler can then work before and after its successor, but any handler can also forget next and silently swallow the request.
The list form keeps handlers ignorant of each other. A driver loop owns the walk, the stop condition and the end-of-chain outcome. For selection and escalation, it is almost always better: the walk lives in one place, records a trail of who was asked, and skips unavailable handlers without any handler knowing.
Chain versus middleware
The Decorator lesson builds a middleware stack where every layer wraps the next and all of them run. That is a pipeline: retry, cache and metrics each add behaviour to one core call. A chain of responsibility is a selection: many candidates, at most one answer. Express's next() blurs the two, because a middleware can either respond (stop) or call next (pass). When reviewing, ask: "Is every handler supposed to run, or exactly one?" If the answer is "every", you want decorators or a pipeline; if it is "the first that can", you want a chain, and the chain needs a defined fallthrough.
Where you already use it
- Exception handling. A thrown exception walks up the stack; the first frame with a matching
catchhandles it. Reaching the top is the end-of-chain case, and the default is to crash. - DOM event bubbling. A click goes to the target, then its ancestors, until a listener calls
stopPropagation(). - Python imports. Each finder on
sys.meta_pathis asked for a module spec; the first answer wins, and none meansModuleNotFoundError. - Firewall rules. An iptables chain is evaluated rule by rule; the first terminating match (
ACCEPT,DROP) decides, and the chain policy is the default.
When not to use it
- The request names its handler. If a type or key identifies the handler, a map lookup is faster and clearer.
- Every handler must run. That is a pipeline or Observer.
- Hundreds of rules on a hot path. The linear walk becomes the bottleneck; see the machine layer.
Reviewing agent-written code
Asked to "let VPs approve up to 50,000", an agent typically inserts another elif, often in the wrong position, because the ordering contract is implicit. Asked to "use Chain of Responsibility", it produces the linked form with an abstract BaseHandler, set_next and super().handle() in every subclass. Steer toward the list form. In review, check that the end-of-chain outcome is explicit and tested, the order is built in one place and pinned by a test, handlers do not mutate the request while declining, and the driver validates what handlers return.
The same shape elsewhere
A chain is the finding things fast shape solved the slow way: a linear scan for the first match. It recurs in drivers sharing an IRQ line (each is asked "is this interrupt yours?"), in support escalation from tier 1 to tier 3, and in an LLM gateway that tries a cheap model before an expensive one. The questions are always the same: what order, what happens on a miss, and how long can the walk get.
Down the stack
Layer 1The contract
The chain promises: handlers are asked in their configured order; the first handler that claims the request decides, and no later handler sees it; a handler that declines leaves no side effects that change the outcome; and if no handler claims the request, the caller receives a distinct, explicit "unhandled" result, not a default approval and not None mistaken for success. A good chain also promises an audit trail (which handlers were consulted, which decided) because escalation systems are judged after the fact. Each handler's own contract is narrow: given a request, return claim or pass, quickly and deterministically for the same input.
Layer 2The mechanism
Real systems mostly use an array plus a loop. Python's import machinery is literally for finder in sys.meta_path: spec = finder.find_spec(name, path, target), stopping at the first non-None spec. glibc's NSS keeps a parsed list of service entries per database with an action table per status (SUCCESS, NOTFOUND, UNAVAIL, TRYAGAIN), so the "pass or stop" decision is configurable per link without changing any service module. Netfilter stores each iptables chain as a contiguous blob of rule entries; the kernel walks entries, and a non-terminating jump target pushes a return position so evaluation resumes in the calling chain afterwards. C++ and Rust exceptions use table-driven unwinding: the compiler emits per-function tables (.eh_frame plus a language-specific data area), and the unwinder walks frames, asking each frame's personality routine whether it has a matching handler.
Layer 3The machine
A list-form chain of N handlers costs up to N indirect calls per request. In Go each is an interface call through an itab, roughly 1 to 2 ns when predicted, so a 6-handler chain costs well under 50 ns. In CPython each hop is a bound-method call of 50 to 100 ns. The linked form adds pointer chasing, since each next is a separate heap object, possibly on another cache line; an array of interface values (16 bytes each in Go: a type word and a data pointer) is walked sequentially.
The cost that matters is linear growth. kube-proxy's iptables mode programmed every Service as rules that packets walked in sequence; with tens of thousands of Services, evaluation and updates became slow, and IPVS mode replaced the walk with hash lookups. nftables sets do the same inside one rule. Application chains follow the same rule: fine at tens of handlers, and at hundreds the front of the chain should become an index (by category or amount band).
A chain built at startup is read-only and needs no locking. Mutable state such as an approver's availability should be read under a lock or swapped in as a new immutable chain, so a request never sees half a reconfiguration.
Why it's built this way
- Requests arrive without knowing who should handle them, so someone must search the candidates.
- Rules overlap (a small gift claim matches both "blocked category" and "lead can approve"), so the search needs a precedence, and an ordered list expresses precedence directly.
- Rules change independently and are owned by different people, so each lives in its own handler and the order lives in one wiring step.
- Requests that match nothing are inevitable, so the end of the chain must have a defined outcome.
- A linear walk costs time per handler, so chains stay short, and long rule sets graduate to indexed dispatch.
Diagram
flowchart LR
R["claim: 2,000.00 travel"] --> P{"policy: blocked category?"}
P -- "yes" --> X1["rejected by policy"]
P -- "no, pass" --> L{"lead: <= 500.00 and not self?"}
L -- "yes" --> A1["approved by lead"]
L -- "no, pass" --> M{"manager: <= 5,000.00?"}
M -- "yes" --> A2["approved by manager"]
M -- "no, pass" --> D{"director: <= 50,000.00 and available?"}
D -- "yes" --> A3["approved by director"]
D -- "no, pass" --> C{"cap: > 50,000.00?"}
C -- "yes" --> X2["rejected: over cap"]
C -- "no, pass" --> U["unhandled: escalate to a human queue"]Worked example
A 900-person company processes about 4,000 expense claims a month. The old router was a 180-line function with 23 branches. An audit found three problems: claims filed by a team lead for exactly the lead's limit were approved by the lead, because <= and the self-approval check were in the wrong order; claims over the director's limit while the director was on leave fell through to a final return approve("finance") that nobody remembered writing; and finance could not answer "who saw this claim?".
The rewrite used a list-form chain (policy, lead, manager, director, cap) with an explicit unhandled outcome that sends the claim to a human queue, and stored the trail with every decision. In the first month 71 percent of claims stopped at the lead, 24 percent at the manager, 4 percent at the director, 0.6 percent were rejected by policy, and 14 claims (0.35 percent) reached the unhandled queue, all during director absences. Before, those 14 had been silently approved by the forgotten fallback.
Adding a VP tier later was one handler inserted between manager and director plus one test pinning the full order. Routing cost about 3 microseconds per claim in Python against a 40 ms request.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
if/elif ladder | Everything visible in one place; fast | Order rules implicit; every change edits shared code | A few stable rules owned by one person |
Linked handlers (next pointers) | A handler can act before and after its successor | Easy to drop the request by forgetting next; no central trail | Frameworks where handlers wrap the rest of the walk |
| List of handlers plus driver | One walk, one fallthrough rule, easy trail and skipping | Handlers cannot post-process | Selection and escalation (approvals, routing, fallbacks) |
| Lookup table by key | O(1); no ambiguity | Only works when the key identifies the handler | Requests carry their type or route |
| Rules engine | Non-engineers edit rules; conflict analysis | Heavy; debugging by trace | Hundreds of business rules that change weekly |
Pitfalls in production
- No defined end-of-chain outcome, so an unclaimed request returns
Noneand the caller treats it as success. Fix: return an explicit unhandled result and route it to a human or an error. - A catch-all handler inserted early (for example "admins can approve anything" placed first) that shadows every later handler. Fix: test the full chain order, and add an assertion that catch-alls are last.
- Handlers that mutate the request while declining (normalising a field, adding a tag), so later handlers see a different request depending on which earlier handlers exist. Fix: treat the request as immutable inside the walk; enrich it in a separate step before the chain.
- Silent growth: a chain that started at 5 handlers has 300 and dominates latency. Fix: index the first stage by key and keep chains short within each bucket.
- A handler that calls external services while deciding (checking an HR system for leave), making routing slow and flaky. Fix: precompute availability and inject it, so
decidestays pure and fast.
Leadership lens
- Treat the chain order as a reviewed artefact. A one-line reorder can change who is allowed to approve money; it deserves a named test and an owner.
- Ask in design review: "What happens when nobody handles it?" If the answer is not an explicit, observable outcome, the design is incomplete.
- Require a decision trail for any chain that grants access, money or escalation. It turns audit questions from code archaeology into a query.
- Watch for teams adding links instead of rethinking. When the chain passes roughly 20 handlers or shows up in profiles, fund the move to indexed dispatch.
- For agents, a useful instruction is "add a handler and insert it at position N in the wiring; do not edit other handlers or the driver". It keeps the change additive and reviewable.
Check yourself
How does a chain of responsibility differ from a stack of middleware decorators?
Show answer
Middleware runs every layer around one core call; a chain asks handlers in order and stops at the first that claims the request. One composes behaviour, the other selects a handler.
Why is the list-plus-driver form usually safer than linked handlers with
nextpointers?Show answer
The walk, the stop condition and the end-of-chain outcome live in one place, so no handler can drop a request by forgetting to call
next, and the driver can record a trail and skip unavailable handlers.A chain has 400 handlers and shows up in CPU profiles. What do you change?
Show answer
Replace the linear walk at the front with an index (a map from category or a range table by amount) so each request reaches a short chain, as IPVS and nftables sets replaced long iptables walks.
A handler returns an outcome the system does not recognise. Whose job is it to catch that?
Show answer
The driver's. It should validate every handler's result and fail loudly naming the handler, rather than passing an unknown verdict to callers.
Watch
- The Chain of Responsibility Pattern Explained and Implemented (Geekific): the classic linked-handler structure built in Java, useful to contrast with the list-plus-driver form.
- Chain Of Responsibility, Design Patterns in 5 minutes (levonog): a short visual summary of passing a request along handlers.
Free resources
- Chain of Responsibility (Refactoring.Guru): the canonical structure, with notes on how it relates to Decorator and Command.
- The import system (Python docs):
sys.meta_pathfinders asked in order, a production chain inside every Python process. - nsswitch.conf (Linux man page): a configurable chain of name sources, with per-status actions that decide whether a miss passes on.
- Event bubbling (MDN): how browser events walk up the tree and how handlers stop the walk.
- IPVS-based in-cluster load balancing deep dive (Kubernetes blog): why long sequential iptables chains did not scale and what replaced them.
- Sets (nftables wiki): turning many linear rules into one lookup.