Skip to content

Chain of Responsibility

Intent

Decouple the sender of a request from the object that handles it by giving several handlers a chance, in order, until one takes it. The sender knows only the first link; each handler decides whether to act, forward, or both. Adding, removing or reordering handlers never touches the sender.

When to use and when not to

Use it when

  • More than one object could handle a request and which one is only known at runtime: the 2000 slot is empty tonight, the fraud rule that fires depends on the payment.
  • The set of handlers or their order must be configurable (middleware order in settings, approval limits per role).
  • Each handler should own one rule and its own state (one slot, one note count) and be testable alone.

Two shapes exist, and you should name the one you mean:

  • First handler wins (the pure form): support escalation, exception handling, fraud rules. Exactly one handler answers; the rest never see the request.
  • Each takes its share (the contributing form): a note dispenser, HTTP middleware, logger propagation. Every link may act and forward.

Leave it out when

  • There is one handler. A chain of one is a function call with ceremony.
  • The routing key is known before you ask (handlers[request.kind]): dict dispatch is explicit and constant-time; a chain is an implicit linear search.
  • The "handlers" are stages that always run and transform the payload. That is a Pipeline, and functools.reduce over a list reads better than linked objects.
  • A silent drop would be a bug you cannot afford; then the chain needs a terminal handler that rejects loudly (see the common mistake), or it should not be a chain.

Structure

Four roles: the abstract Handler with the next link, one concrete handler per note slot, the request that travels, and the client that assembles the chain.

classDiagram
    class CashRequest {
        +amount: int
        +remaining: int
        +notes: Dict~int,int~
    }
    class Handler {
        <<abstract>>
        -_next: Handler
        +set_next(handler: Handler) Handler
        +handle(request: CashRequest) CashRequest
        +forward(request: CashRequest) CashRequest
    }
    class DenominationHandler {
        +denomination: int
        +count: int
        +handle(request: CashRequest) CashRequest
        +dispense(notes: int) None
    }
    class CashDispenser {
        -_head: Handler
        -_slots: List~DenominationHandler~
        -_lock: Lock
        +inventory: Dict~int,int~
        +withdraw(amount: int) Dict~int,int~
    }
    Handler <|-- DenominationHandler
    Handler o-- Handler : next
    CashDispenser *-- DenominationHandler : chains, largest first
    CashDispenser ..> CashRequest : creates
    Handler ..> CashRequest : passes along

CashDispenser builds the chain once and talks only to the head. DenominationHandler.handle plans its contribution and forwards the remainder; nothing inside the chain mutates a slot. CashRequest carries the remainder and the plan so far, which is what lets every link contribute without return-value gymnastics.

Canonical example in Python

The request, the abstract handler and one slot come first (code/patterns/chain_of_responsibility.py, tested by code/patterns/tests/test_chain_of_responsibility.py):

code/patterns/chain_of_responsibility.py — the request, the abstract handler and one slot
@dataclass(slots=True)
class CashRequest:
    """The request that travels down the chain: what is still owed, and the plan so far."""

    amount: int
    remaining: int
    notes: dict[int, int] = field(default_factory=dict)


class Handler(ABC):
    """Abstract handler: owns the link to the next handler and knows how to pass a request on.

    Every concrete handler must decide what to do (``handle`` is abstract); the base
    class only supplies ``forward``. A handler that does not call ``forward`` ends the
    chain, on purpose and visibly. ``set_next`` returns the handler it was given so a
    chain reads ``a.set_next(b).set_next(c)``.
    """

    def __init__(self) -> None:
        self._next: Handler | None = None

    def set_next(self, handler: Handler) -> Handler:
        self._next = handler
        return handler

    @abstractmethod
    def handle(self, request: CashRequest) -> CashRequest: ...

    def forward(self, request: CashRequest) -> CashRequest:
        if self._next is None:
            return request
        return self._next.handle(request)


class DenominationHandler(Handler):
    """One note slot. It takes the notes it can contribute and forwards the remainder.

    ``handle`` plans and never mutates ``count``: the chain decides *whether* the
    amount can be covered, the dispenser decides *when* to commit (``dispense``).
    """

    def __init__(self, denomination: int, count: int) -> None:
        super().__init__()
        if denomination <= 0 or count < 0:
            raise ValidationError("a slot needs a positive denomination and a non-negative count")
        self.denomination = denomination
        self.count = count

    def handle(self, request: CashRequest) -> CashRequest:
        notes = min(self.count, request.remaining // self.denomination)
        if notes:
            request.notes[self.denomination] = notes
            request.remaining -= notes * self.denomination
        return self.forward(request)

    def dispense(self, notes: int) -> None:
        if notes > self.count:
            raise CannotDispenseError(f"slot {self.denomination} holds {self.count}, asked for {notes}")
        self.count -= notes

Three decisions to say out loud:

  • handle is abstract, forward is concrete. Every handler must decide; the base class only knows how to pass a request on. A handler that never calls forward ends the chain on purpose, and the reader can see it.
  • Greedy, largest note first, is correct here only because each denomination divides the next (100 into 500, 500 into 2000). With 2000/500/200 notes it is wrong: for 600 the greedy chain takes a 500 and strands 100, while three 200s would do. The chain decides who contributes, not whether the arithmetic is sound. Say that before the interviewer does.
  • Plan, then commit. handle reads count and never decrements it. A chain that dispensed as it went would hand out the 2000 note and then discover the 100 slot is short: the customer holds part of the money and the slots disagree with the account.

The client owns the order, the validation and the commit:

code/patterns/chain_of_responsibility.py — the client
class CashDispenser:
    """The client: builds the chain once, validates, plans, then commits under one lock.

    Two withdrawals planning against the same slots at the same time could both see
    enough notes, so ``_lock`` serialises plan-and-commit; a slot count never goes
    negative and a failed plan leaves every slot untouched.
    """

    def __init__(self, slots: Iterable[DenominationHandler]) -> None:
        self._slots = sorted(slots, key=lambda slot: slot.denomination, reverse=True)
        denominations = [slot.denomination for slot in self._slots]
        if not denominations or len(set(denominations)) != len(denominations):
            raise ValidationError("a dispenser needs at least one slot and one slot per denomination")
        for upper, lower in pairwise(self._slots):
            upper.set_next(lower)
        self._head: Handler = self._slots[0]
        self._lock = threading.Lock()

    @property
    def inventory(self) -> dict[int, int]:
        return {slot.denomination: slot.count for slot in self._slots}

    def withdraw(self, amount: int) -> dict[int, int]:
        smallest = self._slots[-1].denomination
        if amount <= 0 or amount % smallest:
            raise ValidationError(f"amount must be a positive multiple of {smallest}")
        with self._lock:
            plan = self._head.handle(CashRequest(amount=amount, remaining=amount))
            if plan.remaining:
                raise CannotDispenseError(f"cannot dispense {amount}: short by {plan.remaining}")
            for slot in self._slots:
                slot.dispense(plan.notes.get(slot.denomination, 0))
        return plan.notes

Validation runs once, before the chain: an amount that is not a multiple of the smallest note can never be covered, so no slot should see it. The lock serialises plan-and-commit, because two withdrawals planning against the same slots at once could both see enough notes. A failed plan raises CannotDispenseError and touches nothing, which the demo shows by printing the inventory after the failed 4600.

Running python -m patterns.chain_of_responsibility prints:

slots (largest first): 2000 x 2, 500 x 5, 100 x 10
withdraw 3700: 2000 x 1, 500 x 3, 100 x 2
  slots now: 2000 x 1, 500 x 2, 100 x 8
withdraw 4600: cannot dispense 4600: short by 800
  slots now: 2000 x 1, 500 x 2, 100 x 8
withdraw 2500: 2000 x 1, 500 x 1
  slots now: 2000 x 0, 500 x 1, 100 x 8
rejected before the chain ran: amount must be a positive multiple of 100
--- pure chain: the first fraud rule with an opinion wins ---
US 80.00 USD x1: approved
US 2500.00 USD x1: 2500.00 USD exceeds 1000.00 USD
KP 40.00 USD x1: country KP is denied
DE 40.00 USD x7: 7 attempts in the last hour
--- generator pipeline: each stage keeps what it handles and hands the rest on ---
20 app.http: GET /orders 200
40 app.db: timeout token=***

Pythonic variant

A handler with one method is a function, and a chain is a list of them. The pure form is a loop that stops at the first opinion:

code/patterns/chain_of_responsibility.py — the first rule with an opinion wins
@dataclass(frozen=True, slots=True)
class Payment:
    amount: Money
    country: str
    attempts_last_hour: int


# A link as a function: return a rejection reason, or None to pass the payment on.
type FraudRule = Callable[[Payment], str | None]


def denied_country(denied: frozenset[str]) -> FraudRule:
    return lambda payment: f"country {payment.country} is denied" if payment.country in denied else None


def over_limit(limit: Money) -> FraudRule:
    return lambda payment: f"{payment.amount} exceeds {limit}" if payment.amount > limit else None


def too_many_attempts(max_attempts: int) -> FraudRule:
    def rule(payment: Payment) -> str | None:
        if payment.attempts_last_hour > max_attempts:
            return f"{payment.attempts_last_hour} attempts in the last hour"
        return None

    return rule


def first_rejection(rules: Sequence[FraudRule], payment: Payment) -> str | None:
    """The pure chain: the first rule with an opinion wins; None means every link passed."""
    for rule in rules:
        if (reason := rule(payment)) is not None:
            return reason
    return None

None is the "not mine, pass it on" signal and a string is a verdict, so the protocol is the return type. Order is list order: reordering the rules is a list edit, not a rewiring of set_next calls, and the test that proves a rule is never consulted after a rejection is four lines. This is the shape of fraud rules in the payment gateway and of approval policies anywhere.

The contributing form becomes a pipeline of generator stages. Each stage keeps, drops or rewrites what flows through and hands the rest on, and nothing runs until someone iterates:

code/patterns/chain_of_responsibility.py — generator stages
@dataclass(frozen=True, slots=True)
class LogRecord:
    level: int
    logger: str
    message: str


# A link as a generator stage: keep, drop or rewrite records, then hand the rest on.
type Stage = Callable[[Iterable[LogRecord]], Iterator[LogRecord]]


def at_least(level: int) -> Stage:
    def stage(records: Iterable[LogRecord]) -> Iterator[LogRecord]:
        return (record for record in records if record.level >= level)

    return stage


def redact(secret: str) -> Stage:
    def stage(records: Iterable[LogRecord]) -> Iterator[LogRecord]:
        for record in records:
            yield replace(record, message=record.message.replace(secret, "***"))

    return stage


def run_stages(stages: Sequence[Stage], records: Iterable[LogRecord]) -> Iterator[LogRecord]:
    """Every stage sees what the previous one let through; nothing runs until you iterate."""
    for stage in stages:
        records = stage(records)
    return iter(records)

When is the list enough?

Reach for When
A list of callables and a loop Handlers are stateless or closures, and the order lives in one place (settings, the composition root)
Generator stages Requests arrive as a stream and a link may drop, rewrite or batch them
Linked Handler objects A link owns mutable state (a slot's count), has more than one method (handle, dispense), or the chain is assembled from configuration by someone else
A dict keyed by request type The routing key is known before you ask; chains are for when it is not

Real-world usage

  • Exception handling is the chain you use every day: an exception climbs the call stack and the first matching except handles it; frames above it never see it.
  • logging propagation: a record is offered to its logger's handlers, then to the parent's, up to the root, until a logger with propagate = False stops it. Filters attached to handlers decide per link.
  • sys.meta_path: import asks each finder for a spec in order and takes the first answer that is not None; codecs.lookup does the same with registered search functions.
  • urllib.request.OpenerDirector: handlers are sorted once, then each *_open and http_error_* method is tried until one returns a response.
  • Frameworks: Django middleware (each layer may return a response early or call get_response), Express's next(), servlet filters, DOM event bubbling.
Looks like Chain of Responsibility How to tell them apart
Pipeline and Middleware Every stage runs and the payload is transformed in order; nothing is handled and dropped. A chain is about who answers; a pipeline is about what happens to the data. The dispenser sits between the two, which is why the generator form fits it.
Decorator The same silhouette, objects wrapping objects behind one interface, but a decorator always calls through and adds behaviour around a fixed inner object. A link may stop the request, and the point is the search for a handler.
Command The command is the request object; the chain is how it finds a receiver. They compose: an ATM withdrawal command travels down the denomination chain.
Strategy The client picks one strategy up front; a chain discovers the handler by asking each in turn. If you know the key, use a strategy or a dict.
Observer Every observer gets the event and none consumes it; order is unspecified. Chain handlers are ordered, and the first (or each) may end the request.
Composite A tree, not a line, but chains often run along a composite's parent links: DOM bubbling and logger hierarchies are chains over trees.
State One current object handles every event and switches itself; nothing is forwarded along a line.

Where it appears in LLD problems

Interview tips

Interview tip

Name the shape before you draw it: "first handler wins" or "each link contributes". Then name the three things the client owns: the order, the validation before the chain, and the commit after it. For the dispenser, volunteer both caveats (greedy needs divisible denominations; plan before you mutate) before you are asked, and offer the list-of-callables form as what you would actually ship for stateless rules.

Common mistake

A chain with no terminal decision. When every link forwards and the last _next is None, the request vanishes: the dispenser returns a partial plan, the middleware returns None, the ticket is never answered. End the chain with an explicit outcome (raise, reject, default) and test the fall-through path. Runner-up: handlers that mutate shared state before the chain has finished deciding.