Engineering Atlas › Low-Level Design

Chain of Responsibility pattern

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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 here

A 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

When not to use it

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

  1. Requests arrive without knowing who should handle them, so someone must search the candidates.
  2. 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.
  3. Rules change independently and are owned by different people, so each lives in its own handler and the order lives in one wiring step.
  4. Requests that match nothing are inevitable, so the end of the chain must have a defined outcome.
  5. 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

OptionStrengthsWeaknessesChoose when
if/elif ladderEverything visible in one place; fastOrder rules implicit; every change edits shared codeA few stable rules owned by one person
Linked handlers (next pointers)A handler can act before and after its successorEasy to drop the request by forgetting next; no central trailFrameworks where handlers wrap the rest of the walk
List of handlers plus driverOne walk, one fallthrough rule, easy trail and skippingHandlers cannot post-processSelection and escalation (approvals, routing, fallbacks)
Lookup table by keyO(1); no ambiguityOnly works when the key identifies the handlerRequests carry their type or route
Rules engineNon-engineers edit rules; conflict analysisHeavy; debugging by traceHundreds of business rules that change weekly

Pitfalls in production

Leadership lens

Check yourself

  1. 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.

  2. Why is the list-plus-driver form usually safer than linked handlers with next pointers?

    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.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →