Chain of responsibility
Passing a request along handlers until one takes it.
01The intuition
The problem it solves
A school help desk program routes a request. A missing textbook goes to the librarian, a fee question to the office, a broken laptop to IT, and anything with the word "urgent" to the principal first. The obvious code is one function that checks every rule in a fixed order. Each new kind of request means editing that function, and the order of checks, which matters, is buried in the middle of it.
def route(req: "Request") -> str:
if "urgent" in req.text.lower():
return "principal"
if req.kind == "book":
return "librarian"
if req.kind == "fee":
return "office"
if req.kind == "laptop" and req.student.year >= 9:
return "it"
return "front-desk" # every new desk edits this function
Build it one piece at a time
Step one: make each rule its own small object with one method: given a request, either handle it or say "not mine". Nothing in a rule knows about the other rules.
from typing import Protocol
class Handler(Protocol):
def handle(self, req: "Request") -> str | None: ...
class UrgentDesk:
def handle(self, req: "Request") -> str | None:
return "principal" if "urgent" in req.text.lower() else None
class BookDesk:
def handle(self, req: "Request") -> str | None:
return "librarian" if req.kind == "book" else None
class LaptopDesk:
def handle(self, req: "Request") -> str | None:
return "it" if req.kind == "laptop" and req.student.year >= 9 else None
Step two: put the rules in a line and pass the request along it until one takes it. This line is the chain; the order is now data you can read and rearrange.
class Chain:
def __init__(self, handlers: list[Handler], fallback: str) -> None:
self.handlers = handlers
self.fallback = fallback
def route(self, req: "Request") -> str:
for h in self.handlers:
result = h.handle(req)
if result is not None:
return result
return self.fallback
chain = Chain([UrgentDesk(), BookDesk(), FeeDesk(), LaptopDesk()], "front-desk")
Step three: once rules are objects, each can also pass a changed request onward rather than stopping. That is how filters and middleware work: each link may act and then let the rest run.
class Middleware(Protocol):
def process(self, req: "Request", next_: "Callable[[Request], str]") -> str: ...
class Logging:
def process(self, req, next_):
log.write(f"in: {req.kind}")
out = next_(req)
log.write(f"out: {out}")
return out
Why it works
The principle: a request should not know who will serve it, and a handler should not know who comes after it. The invariant is that a request is handled by the first handler that accepts it, in list order, and that every request reaches either a handler or the fallback. Where it breaks: two handlers that both match and the order was never written down, a handler that mutates the request so later handlers see different data than you expect, and a chain with no fallback where a request silently vanishes. Interviewers test by asking you to add a handler that must run before an existing one, and by asking what happens when none match.
The trade-off you must say out loud
Reading the flow means reading the list and every handler; a single function with five ifs is easier to follow when the rules are stable. Every request walks the chain, so a long chain with cheap matches is fine but a long chain with expensive matches needs an index or a dispatch table. The follow-up is usually whether handlers should stop the chain or all run, which is the difference between routing and a filter pipeline.
Where it shows up in the 100 Google problems
- Logging framework: a log record passes through level filters, formatters and sinks in a chain.
- HTTP router with path parameters: middleware runs before the matched handler; the router itself is the dispatching link.
- ATM: dispensing cash walks a chain of note denominations, each taking what it can.
- Coupon and discount system: eligibility rules run in order and the first rejecting rule wins.
- Rule and workflow engine: ordered rules evaluated until one fires, registered at runtime.