Concepts / Behavioral patterns / Chain of responsibility

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

02Used in these problems