Chain of Responsibility (handlers pass the request along)
When to use
- You have a request/record that should flow through handlers in order: validate → enrich → mask → route.
- Some handlers may stop early (reject/drop) or modify the request.
- You want to add/remove/reorder steps without changing each handler.
Avoid when you only need a straight list of pure functions (then just loop).
Diagram (text)
Event ─→ Validate ─→ MaskPII ─→ Router ─→ (done)
│(raise on bad) │(side effect: writes to sink)
Python example (≤40 lines, type-hinted)
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, Optional, Dict, List
Event = Dict[str, object]
class Handler(Protocol):
def set_next(self, nxt: "Handler") -> "Handler": ...
def handle(self, e: Event) -> Event: ...
@dataclass
class BaseHandler:
nxt: Optional[Handler] = None
def set_next(self, nxt: Handler) -> Handler: self.nxt = nxt; return nxt
def handle(self, e: Event) -> Event: return self.nxt.handle(e) if self.nxt else e
class Validate(BaseHandler):
def handle(self, e: Event) -> Event:
if "user" not in e or "email" not in e: raise ValueError("invalid event")
return super().handle(e)
class MaskPII(BaseHandler):
def handle(self, e: Event) -> Event:
email = str(e["email"]); user, _, dom = email.partition("@")
e["email"] = "***@" + dom if dom else "***"
return super().handle(e)
@dataclass
class Router(BaseHandler):
sink: Dict[str, List[Event]]
def handle(self, e: Event) -> Event:
self.sink.setdefault(str(e.get("type","other")), []).append(dict(e))
return super().handle(e)
# build the chain
def build_chain(sink: Dict[str, List[Event]]) -> Handler:
head = Validate(); head.set_next(MaskPII()).set_next(Router(sink))
return head
Tiny pytest (cements it)
def test_chain_runs_and_masks():
sink = {}
chain = build_chain(sink)
evt = {"type":"signup","user":"u1","email":"a@b.com"}
out = chain.handle(evt)
assert sink["signup"][0]["email"].startswith("***@")
assert out is evt # same object flowing through
def test_chain_stops_on_bad_event():
sink = {}; chain = build_chain(sink)
import pytest
with pytest.raises(ValueError): chain.handle({"type":"signup","email":"x@y"})
assert sink == {} # router not reached
Trade-offs & pitfalls
- Pros: Pluggable steps; simple add/remove/reorder; each handler focused; easy to test in isolation.
- Cons: Control flow is implicit; debugging order can be tricky.
- Pitfalls:
- Forgetting to call
super().handle(e)→ chain breaks silently. - Hidden side effects—document what each handler reads/writes.
- Overusing exceptions for control flow—prefer clear returns when not an error.
- Forgetting to call
Pythonic alternatives
- Plain function pipeline:
for fn in steps: e = fn(e)(best when no early-stop/branching). - Middleware style (ASGI/WSGI): handlers accept
(event, next)and decide whether to callnext(). - Composite if you just need grouping, not early-stop.
- Decorator for cross-cutting concerns around a single handler.
Mini exercise
Add a DropPII handler that removes the email field entirely when e.get("type") == "internal", then continues. Insert it between Validate and MaskPII. Write a test proving internal events reach Router without any email key.
Checks (quick checklist)
- Each handler does one job and then
super().handle(e). - Order is explicit and tested.
- Early-stop behavior is clear (raise or return).
- Handlers don’t depend on each other’s internals—only on the event.
- Easy to add/remove/replace a handler without touching others.
Say “next” and we’ll do #20 Mediator (a coordinator that reduces many-to-many chatter between components, e.g., an orchestrator and multiple workers).




