Chain of Responsibility: Passing Requests Through Handlers
What This Concept Is
Chain of Responsibility (CoR) passes a request along a linked series of handlers. Each handler:
- decides whether to handle the request
- optionally produces a result
- decides whether to forward the request to the next handler
Two common shapes:
- Single-handler chain: the first handler that claims the request processes it; others are skipped. Classical CoR, used in GUI event bubbling.
- Pipeline (middleware): stages may transform, forward, wrap downstream work or terminate under a stated policy. Used in HTTP middleware, logging filters and validators.
These are related processing structures with different contracts. Middleware may also wrap downstream execution with before/after behavior.
Why It Matters Here
CoR shows up as the underlying shape of:
- HTTP middleware stacks (Express, ASP.NET, Rack, Koa, Axum)
- logging filters
- GUI event dispatch (key presses bubble up through containers)
- approval workflows
- request validation pipelines
Recognizing it prevents reinventing its bugs (infinite loops, handlers that forget to forward, order-dependence no one documented).
Concrete Example
A logger pipeline in TypeScript: console -> file -> metrics. Levels select which destinations emit; they are not the handler order.
type Level = "debug" | "info" | "warn" | "error";
const order: Level[] = ["debug", "info", "warn", "error"];
interface LogHandler {
handle(level: Level, msg: string): void;
setNext(h: LogHandler): LogHandler;
}
abstract class BaseHandler implements LogHandler {
private next?: LogHandler;
setNext(h: LogHandler) { this.next = h; return h; }
handle(level: Level, msg: string) {
this.emit(level, msg);
this.next?.handle(level, msg);
}
protected abstract emit(level: Level, msg: string): void;
}
class ConsoleHandler extends BaseHandler {
constructor(private min: Level) { super(); }
protected emit(level: Level, msg: string) {
if (order.indexOf(level) >= order.indexOf(this.min)) {
console.log(`[${level}] ${msg}`);
}
}
}
class FileHandler extends BaseHandler {
protected emit(level: Level, msg: string) {
if (level === "error") {
// writeFileSync("errors.log", msg + "\n", { flag: "a" });
}
}
}
class MetricsHandler extends BaseHandler {
private counts: Record<Level, number> = { debug: 0, info: 0, warn: 0, error: 0 };
protected emit(level: Level, msg: string) { this.counts[level]++; }
}
const root = new ConsoleHandler("info");
root.setNext(new FileHandler()).setNext(new MetricsHandler());
root.handle("debug", "starting"); // not printed (below min), but reaches metrics
root.handle("error", "disk full"); // printed and counted; the file write above is a placeholder
Each handler has one job. Reordering or removing a handler is a local change. Adding a new destination is a new class and one wiring line.
Common Confusion / Misconception
- Forgetting to forward. A middle handler that always handles silently swallows requests. Bugs look like "why didn't that reach X?". Specify continuation and the terminal result. Authorization-like chains must not accidentally allow an unhandled request.
- Order dependence with no documentation. If authentication must run before rate limiting, write that in a comment. Production systems fail weirdly when someone reorders the chain innocently.
- Unbounded chains. A chain that forwards to itself, or a graph built accidentally, loops forever. Test with a finite chain; consider visited-set detection in general-purpose CoR libraries.
- CoR when a map would do. If "the chain" is really "look up by type and dispatch", a dictionary is clearer. CoR earns its shape when order matters and handlers need to decide.
- CoR vs Decorator confusion. Same class shape; different intent. A decorator wraps an operation to add behavior; CoR selects or sequences handlers. Wrappers can also reject or cache without forwarding.
How To Use It
- Identify the "series of possibly independent checks or transformations" shape.
- Define a handler interface with
handle(request)and, if needed,setNext(handler). - Implement the chosen continuation protocol, with an explicit terminal outcome. Forwarding by default suits some pipelines but is not a universal safety rule.
- Build the chain explicitly in client code or via a builder; do not hardcode ordering inside handlers.
- Decide on the policy: handle-and-stop, handle-and-forward, or mixed per handler. Document it.
- Log entry/exit per handler during development to see what the chain actually did.
Check Yourself
- What is the difference between the classical "first handler wins" shape and the pipeline shape?
- When is forwarding appropriate, and what should happen when no handler decides?
- How is CoR different from a
switchover handler type? - When is CoR the wrong pattern, and what replaces it?
Mini Drill or Application
Build a request validation chain for an HTTP endpoint:
- Handlers:
AuthCheck,RateLimit,PayloadSize,SchemaValidate. - Each may reject (stop the chain with an error) or forward.
- Write one test per handler: the request it rejects, the request it lets through.
- Reorder two handlers and produce a trace showing whether the policy changed. A one-line edit can still change behavior.
Read This Only If Stuck
- Head First: Chain of Responsibility (Leftover Patterns) (book index)
- GoF: Chain of Responsibility Implementation (book index)
- GoF: Chain of Responsibility Sample Code (book index)
- GoF: Chain of Responsibility Known Uses (book index)
Contract and transfer
L06 has three outcomes: pass, allow, deny. First allow/deny stops, and no decision means deny. This illustrates a first-match policy; it must not be used unchanged when every mandatory check must pass. For example, authentication allow followed by size-limit deny would short-circuit the size check. In an all-checks-required pipeline, a successful check should continue, and only the terminal success after all mandatory checks should allow.
Order changes are semantic changes when handlers have effects or overlap. A cheap size rejection before a costly parse can reduce work; disclosure rules and audit requirements may constrain error precedence. Test both the returned decision and the handlers that must not run. Exceptions should have an explicit policy rather than silently forwarding into a successful fallback.
Transfer: a middleware handler invokes next twice. A downstream payment executes twice even though each handler looks locally reasonable. State a single-call continuation contract and produce a trace that rejects the duplicate invocation. P17 and L06 test stopping and error propagation.