Appearance
9.4.16 — Chain of Responsibility
What the original Gang of Four book says: Give more than one object a chance to handle a request, so the sender is not tied to any one receiver. Line the receivers up, and pass the request along the line until one of them handles it.
What that means when you are actually writing code: A request has to pass through a series of independent steps, in a set order. Each step can handle it, change it, or reject it outright. Instead of a pyramid of nested
ifstatements, make each step an object in a list.
If you have ever written Express middleware, an ASP.NET pipeline, a Netty handler chain, a servlet filter, or Redux middleware, then you have already used Chain of Responsibility. And you used the modified form of it, which turned out to be far more useful than the original.
This chapter teaches both forms and is honest about the gap between them. The original says "pass it along until one handler takes it". The modern pipeline form says "pass it through everyone, each doing a bit of work, and any of them can stop the line". Most of the real-world value lives in that second form, so we spend most of our time there.
1. The story: the request handler that became a pyramid
An HTTP handler starts clean and then slowly gathers concerns until it looks like this:
typescript
async function handleRequest(req: Request, res: Response) {
const start = Date.now();
if (!req.headers.authorization) return res.status(401).json({ error: "no token" });
const user = await verifyToken(req.headers.authorization);
if (!user) return res.status(401).json({ error: "bad token" });
if (!user.roles.includes("admin")) return res.status(403).json({ error: "forbidden" });
const count = await redis.incr(`rl:${user.id}`);
if (count === 1) await redis.expire(`rl:${user.id}`, 60);
if (count > 100) return res.status(429).json({ error: "rate limited" });
const cached = await cache.get(req.url);
if (cached) { log.info({ ms: Date.now() - start, hit: true }); return res.json(cached); }
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ errors: parsed.error.issues });
try {
const result = await businessLogic(parsed.data, user); // ← the actual job: one line
await cache.set(req.url, result, 60);
log.info({ ms: Date.now() - start, hit: false });
return res.json(result);
} catch (e) {
log.error({ e }); return res.status(500).json({ error: "internal" });
}
}That is twenty lines, of which exactly one is the endpoint's actual purpose. And this is only one endpoint. The other forty each carry their own slightly different copy of the same twenty lines. Here is what that costs.
The cross-cutting concerns are copied into every handler, and they drift apart. One endpoint forgets rate limiting entirely. Another checks the user's role before verifying who they are, so an anonymous request gets a confusing 403 instead of a clear 401. A third starts its timer after doing some work, so its latency numbers are quietly wrong. Each of these divergences is a bug, and each one arrived because somebody copied a slightly different starting point.
The order is implicit and easy to get wrong. Authentication must come before authorization, because you cannot check somebody's role until you know who they are. Rate limiting should come before the expensive work, because the whole point is to not do that work. Caching must come after authorization, because otherwise a cached admin response could be served to an anonymous visitor, which is a genuine security hole and exactly the class of bug discussed in Decorator section 6.
You cannot vary the concerns per route. The public health-check endpoint needs no authentication at all. The file-upload endpoint needs a different body-size limit. The webhook endpoint needs signature verification instead of a token. With everything written inline, each of those variations is another copy of the whole block.
Each concern is impossible to test on its own. To test "does rate limiting return a 429?", you need a full request, a real authenticated user, a body that passes validation, and a mocked business function, just to reach the one line you care about.
Adding a concern means editing every handler. Want to add request-ID propagation across the whole service? That is a change to forty files.
The exit paths multiply. There are seven return statements before the real work even starts. Insert a new concern in the wrong place and it silently bypasses the ones after it.
Look at what all of this really is: a set of independent steps, applied in a defined order to a passing request, where each step can either handle the request and stop, or let it continue. That is a chain.
typescript
app.use(requestId, logger, authenticate, authorize("admin"), rateLimit(100), validate(schema), cache(60));
app.post("/reports", handler); // ← the handler is now only the business logic2. How you arrive at the pattern
Step 1 — Start naive. Nested if statements in one function. That is right when there are two checks that will never change and are used in exactly one place.
Step 2 — Wait for the force. A request has to pass through several independent steps in a defined order, where each step can handle it and stop, change it and continue, or do nothing and continue — and the set of steps varies by context, whether that is per route, per tenant, or per environment.
Step 3 — Draw the line between what varies and what stays fixed.
| What varies | which steps apply, how many of them, and in what order |
| What stays fixed | the shape of a step — (request, next) → response — and the fact that a request flows through them |
The fixed shape becomes the handler interface. Each concern becomes one handler. And the order becomes data — a list — which is the whole point. The order stops being something implied by line numbers and becomes something you can read, reorder, and test.
Step 4 — Decide when the choice gets made. At composition time. The chain is assembled at startup, or per route, or per tenant from configuration, and then requests flow through whatever was assembled.
Step 5 — Name the pattern and be honest about the costs. The name is Chain of Responsibility, and in its modern form it goes by pipeline, middleware, filter chain, or interceptor chain.
The first cost is that nothing guarantees anybody handles the request. This is the classic hazard from the original book: a request can fall off the end of the chain unhandled, so you need a terminal handler, which section 6.1 covers.
The second cost is debugging by traversal. A stack trace through eight middleware frames is noisier than one through a straight function, and the question "which layer returned this 403?" needs deliberate instrumentation to answer.
The third cost is that order dependencies are real and invisible if you do not write them down.
The fourth cost is performance, because every request travels through every link, so an expensive check placed early costs every single request.
The fifth cost is the shared, mutable request state. The middleware habit of attaching things like req.user is convenient and untyped, and it produces the "who set this property, and where?" question that plagues large Express applications.
3. The mental model
In one sentence: a chain is an assembly line for a request, where each station can finish the job and send the request back, add something and pass it on, or wave it through untouched.
The analogy that makes it stick — an expense approval. A €40 claim is approved by your manager. A €4,000 claim goes from your manager to a director. A €400,000 claim goes from your manager to a director to the CFO to the board. The person submitting the claim does not know who will approve it, and each approver knows only their own limit and who is next in line. Add a compliance step for international claims, and you slot in one new link without changing any approver's own logic. This is pure Chain of Responsibility: exactly one link handles the request, and the rest simply pass it along.
The second analogy, for the form you will actually write — airport security. Check-in, document check, X-ray, passport control, boarding. Each station does something to you and passes you on, any station can stop you completely, and the order matters absolutely, because you cannot board before the X-ray. This is the pipeline form, and it is what middleware is.
The distinction between the two, stated plainly, because it is the most useful thing on this page. The original Chain says: find the one handler that can deal with this request. The pipeline says: pass through all of them, each contributing something, any of them able to abort. The pipeline form is the one that took over the industry, and it adds a third property the original does not have: the request comes back. Middleware wraps a next() call, so each layer gets control on the way in and again on the way out, which is how one layer can time the whole request, catch an error from further down, or transform the response on the way back.
When to reach for it. The signals are:
- "apply these checks in order"
- "any of these might handle it"
- "cross-cutting concerns"
- "run this before and after every request"
- "the pipeline", "filters", "interceptors", "middleware", "hooks"
- approval and escalation workflows
- "try each of these parsers until one succeeds"
- a function whose first fifteen lines are guard clauses that appear in fifteen other functions
4. Structure
The participants are simple. The Handler is the interface, which represents one step. A ConcreteHandler is one concern, which decides whether to handle, transform, or pass. The Client builds the chain and sends the request. And in the pipeline form there is next, the continuation that represents the rest of the chain.
There are three structural choices to make on purpose. First, linked handlers or a list: the original has each handler hold a pointer to its successor, while modern implementations hold an array and compose it. The array is better, because the chain becomes something you can inspect, reorder, and test as data. Second, where the chain ends: with an explicit terminal handler that always handles, never with an empty next (section 6.1). Third, one-way or round-trip: the original is one-way, while middleware is round-trip through next(), and the round trip is where most of the value lives.
5. The code, walked through line by line
typescript
type Middleware<Ctx> = (ctx: Ctx, next: () => Promise<void>) => Promise<void>; // (1)
export function compose<Ctx>(mw: readonly Middleware<Ctx>[]): (ctx: Ctx) => Promise<void> {
return function run(ctx: Ctx): Promise<void> {
let lastCalled = -1; // (2) guard against calling next() twice
function dispatch(i: number): Promise<void> {
if (i <= lastCalled) return Promise.reject(new Error("next() called multiple times"));
lastCalled = i;
const fn = mw[i];
if (!fn) return Promise.resolve(); // (3) the end of the chain
return Promise.resolve(fn(ctx, () => dispatch(i + 1))); // (4) next = the REST of the chain
}
return dispatch(0);
};
}
// ---- concerns, each testable on its own ------------------------------------
const requestId: Middleware<Ctx> = async (ctx, next) => {
ctx.id = ctx.headers["x-request-id"] ?? randomUUID();
ctx.res.setHeader("x-request-id", ctx.id);
await next(); // (5) nothing after: pure pass-through
};
const timing: Middleware<Ctx> = async (ctx, next) => {
const start = performance.now();
try {
await next(); // (6) do work "around" the rest of the chain
} finally {
const ms = performance.now() - start; // runs even if something downstream threw
log.info({ id: ctx.id, path: ctx.path, status: ctx.status, ms }, "request");
}
};
const authenticate: Middleware<Ctx> = async (ctx, next) => {
const token = ctx.headers.authorization?.replace(/^Bearer /, "");
if (!token) return ctx.fail(401, "missing token"); // (7) SHORT-CIRCUIT: it does not call next()
const user = await verify(token);
if (!user) return ctx.fail(401, "invalid token");
ctx.user = user; // (8) enrich, then continue
await next();
};
const authorize = (role: Role): Middleware<Ctx> => async (ctx, next) => { // (9) parameterized
if (!ctx.user) throw new Error("authorize requires authenticate before it"); // ordering check
if (!ctx.user.roles.includes(role)) return ctx.fail(403, "forbidden");
await next();
};
const app = compose([requestId, timing, errorBoundary, authenticate, authorize("admin"),
rateLimit(100), validate(schema), cache(60), businessHandler]);Now the numbered decisions.
(1) The interface is (ctx, next) => Promise<void>, one type for every concern in the system. Note that next is a function, not the next handler object. It is the closure form of "the rest of the chain", and it is what makes the round trip possible.
(2) Guard against next() being called twice. A middleware that calls next() in two different branches will run the rest of the chain twice, which means duplicate database writes, two responses, and a "headers already sent" error. The guard turns a bewildering bug into a clear one, and every serious middleware library, including Koa and Express, has this check.
(3) Running off the end resolves quietly, which is exactly why a terminal handler has to be the last element (section 6.1). Otherwise a request that nobody handled simply returns nothing at all.
(4) next is the continuation. The expression () => dispatch(i + 1) closes over the current position, so each layer receives "everything after me" as something it can call. This one line is the entire mechanism.
(5) The simplest shape is: do something, then await next(). That is pure enrichment, adding a request ID and moving on.
(6) try { await next() } finally { … } is the round-trip idiom, and it is the reason middleware beats plain chaining. The timing layer measures the whole downstream chain, and the finally block guarantees the log line even when a layer further down throws. The same shape gives you error boundaries, response compression, transaction commit and rollback, and cleanup.
(7) Not calling next() is how a link short-circuits. There is no special "handled" flag. Omitting the call to the rest of the chain is how a link answers the request. This is elegant, and it is also the source of the classic bug: a middleware that forgets to call next() on some path silently hangs the request forever, because nothing responds and nothing continues.
(8) Enrichment happens through the context. One layer sets ctx.user, and later layers read it. This is convenient, and it is also an admitted weakness, because the dependency is implicit, untyped by default, and creates ordering requirements you cannot see. Section 6.2 covers how to make it safer.
(9) Parameterized middleware is a factory that returns a middleware, which is the same "value differences are parameters" rule from Strategy. And the explicit check inside authorize is worth copying everywhere: make ordering requirements fail loudly on the very first request, rather than silently letting an unauthorized user through.
What this does when you run it:
typescript
// GET /reports with no token:
// requestId → timing → errorBoundary → authenticate ⇒ 401, the chain stops here
// the response unwinds back out through errorBoundary and timing → logs { status: 401, ms: 1.2 }
// GET /reports as an admin, with a cache hit:
// … → cache HIT ⇒ responds, and businessHandler never runs → logs { status: 200, ms: 3.4 }5.1 The classic form, and when it is still right
typescript
abstract class Approver {
constructor(private readonly next?: Approver) {} // linked, not a list
handle(req: ExpenseRequest): Decision {
if (this.canApprove(req)) return this.decide(req); // ← handle it and stop
if (!this.next) return { status: "escalated_to_nobody" }; // ← the terminal case, made explicit
return this.next.handle(req); // ← pass it along
}
protected abstract canApprove(req: ExpenseRequest): boolean;
protected abstract decide(req: ExpenseRequest): Decision;
}
class Manager extends Approver { canApprove = (r) => r.amount <= 1_000_00; /* … */ }
class Director extends Approver { canApprove = (r) => r.amount <= 50_000_00; /* … */ }
class Cfo extends Approver { canApprove = () => true; /* the terminal handler */ }
const chain = new Manager(new Director(new Cfo()));
chain.handle({ amount: 25_000_00 }); // Manager passes it up → Director approvesThe classic form fits when exactly one handler should deal with the request and the handlers have real eligibility rules: approval hierarchies, escalation tiers, support-ticket routing, exception handlers, or "try each parser until one accepts this file". Here the natural question is "who can take this?" rather than "what should be applied to this?", and the round trip is not needed.
5.2 Python: decorators, and the WSGI/ASGI chain
python
from typing import Callable, Awaitable
Handler = Callable[[dict], Awaitable[dict]]
Middleware = Callable[[Handler], Handler]
def timing(next_: Handler) -> Handler: # a middleware WRAPS the next handler
async def handler(scope):
start = time.perf_counter()
try:
return await next_(scope)
finally:
log.info("%s took %.1fms", scope["path"], (time.perf_counter() - start) * 1000)
return handler
def authenticate(next_: Handler) -> Handler:
async def handler(scope):
user = await verify(scope["headers"].get("authorization"))
if user is None:
return {"status": 401} # short-circuit: never calls next_
scope["user"] = user
return await next_(scope)
return handler
def compose(*middleware: Middleware) -> Middleware:
def wrap(app: Handler) -> Handler:
for mw in reversed(middleware): # reversed, so the first listed runs first
app = mw(app)
return app
return wrap
app = compose(timing, authenticate, rate_limit(100))(business_handler)Notice the reversed call. Wrapping from the inside out means the last wrap becomes the outermost layer, so composing in reverse makes the listed order the execution order. Getting this backwards is the standard first bug when you hand-roll composition, in any language. This is also exactly how ASGI works, and it makes the relationship to Decorator unmistakable: each middleware is a decorator over the handler, and the chain is a stack of decorators.
6. Going deeper
6.1 The unhandled-request hazard, and terminal handlers
The original chain has a defect that even the intent statement admits: there is no guarantee the request will be handled. A request can travel through every link and fall off the end, and the failure is silent — a null return, an empty response, a hung promise. In the middleware form it is worse, because the symptom is a request that never completes: no error, no log, just a client timeout thirty seconds later.
There are three defences, and you want all three.
First, a terminal handler that always handles. The last link is a catch-all: a 404 handler, a Cfo who can approve any amount, a default parser that raises a clear "unsupported format" error. Never let "end of chain" be the answer to a request.
Second, fail loudly on chain exhaustion. If a chain must be handled by somebody, then throwing an UnhandledRequest error at the end beats returning undefined, because the difference is a stack trace versus a mystery.
Third, detect the forgotten next(). In development, wrap each middleware with a timeout that logs "middleware X neither responded nor called next()". That converts the single most confusing middleware bug into a message you can act on immediately.
The mirror-image hazard is double handling: a link that both responds and calls next(), so a later link responds again and you get "headers already sent". The lastCalled guard in section 5 catches the double-next() case, and a ctx.responded flag checked before writing catches the double-response case.
6.2 Order is the policy, and the shared context is the trap
Every non-trivial chain has ordering requirements that are invisible in the code and catastrophic when wrong, so write them down.
| The requirement | Why it exists | The symptom when it is violated |
|---|---|---|
| error boundary outermost | it must catch everything below it | an unhandled rejection, or a 500 with no body |
| request ID before logging | logs need the correlation ID | logs that cannot be traced |
| timing outside the work it measures | otherwise it measures only part | flat, wrong latency numbers |
| authenticate before authorize | roles come from the authenticated user | a 403 for anonymous requests, or worse, an authorization check against undefined that passes |
| authorize before cache | otherwise cached private data is served to anyone | one user's data leaks to another |
| rate limit before expensive work | the point is to avoid the work | a limiter that costs as much as the endpoint |
| body parsing before validation | validate needs the parsed body | validating a raw stream |
| compression outermost of the response writers | it must see the final bytes | double-compressed or uncompressed responses |
| CORS and preflight early | preflight must not hit authentication | browser errors on OPTIONS requests |
| transaction inside auth, outside the handler | do not open a transaction for a rejected request | a lock held during a 403 |
The cache-inside-authorization row is a real security breach, not a hypothetical. A cache keyed by URL and placed before authorization will serve one user's response to another user. Any time you reorder a chain, re-derive this table.
The shared mutable context is the pattern's biggest usability weakness. Things like ctx.user, ctx.tenant, and ctx.parsedBody are set by one layer and read by another, with nothing at compile time linking them. There are three mitigations, in increasing strength:
typescript
// (a) Type the context progressively, so a middleware ADDS a property to the type.
type Authenticated<C> = C & { user: User };
const authenticate: <C extends Ctx>(ctx: C, next: (c: Authenticated<C>) => Promise<void>) => Promise<void>;
// now `authorize` can require Authenticated<Ctx>, and the compiler enforces the ORDER.
// (b) Assert the dependency explicitly, at the top of the dependent middleware.
if (!ctx.user) throw new Error("authorize requires authenticate before it");
// (c) Namespace and freeze: ctx.state.auth = Object.freeze({ user }), one owner per key.Option (a) is the strongest, and it is what typed frameworks such as tRPC, Hono, and NestJS interceptors offer: the ordering requirement becomes a type error rather than a production incident. Where the framework does not support it, option (b) costs one line and turns a subtle failure into an immediate one.
6.3 Performance and short-circuit ordering
Every request pays for every link, so the chain's order is also its cost model. Two rules follow.
Put the cheap, likely-to-reject links first. A signature check that rejects thirty percent of webhook traffic in fifty microseconds should come before a permission lookup that hits the database. Reordering a chain to put the highest-rejection, lowest-cost link first is one of the easiest latency wins available, and it is only possible because the order is data.
Skip links that do not apply, rather than making every link check whether it applies. A multipart parser should not run for JSON requests. There are two ways to spell this. Conditional mounting, such as app.use("/api/*", auth), is preferable because the link never runs at all. An early return inside the link, such as if (!ctx.is("multipart")) return next(), still pays the cost of the call. Per-route chains built at startup are better than per-request conditionals, because the decision is made once rather than a million times.
Note also that async chains allocate memory: each next closure is an object per request per link. For a twenty-link chain at fifty thousand requests per second, that is a million allocations a second — usually irrelevant, occasionally the thing your profiler points at. When it matters, the fix is fewer links, not a different pattern.
7. Where you would actually use this
(a) HTTP middleware. Express, Koa, ASP.NET Core, Rack, Django, Gin — the dominant use. Authentication, logging, compression, CORS, body parsing, rate limiting, and error handling (9.9.1).
(b) Approval and escalation workflows. Expense approval, discount authorization, content-moderation tiers, and support-ticket escalation from level one to level two to level three. This is pure original-form Chain: one handler decides, and each level's eligibility rule is its own object.
(c) Event and DOM propagation. DOM bubbling is a chain built by the document tree. An event travels up through the ancestors until one of them calls stopPropagation(), which is the short-circuit, and that is why event delegation works.
(d) Logging frameworks. Log4j and logback appenders, and Python's logging handler hierarchy: a record propagates through handlers and up through parent loggers, each deciding whether to emit it, until propagate = False stops it.
(e) Exception handling. The language's own mechanism. An exception propagates up the call stack until a catch handles it, which is Chain of Responsibility built into the runtime, with the stack as the chain and the default crash as the terminal handler.
(f) Request routing and content negotiation. Try each route matcher until one matches; try each parser until one accepts the payload; try each authentication scheme, bearer then API key then session cookie, until one succeeds.
(g) Network stacks and proxies. Netty's ChannelPipeline, Envoy's HTTP filter chain, nginx's phase handlers, and iptables rules evaluated in order with ACCEPT and DROP short-circuits.
(h) Compiler and data pipelines. Lexer, then parser, then type checker, then optimizer passes, then code generator; or ETL stages where each transform may reject a record. Each pass may halt the pipeline with an error.
(i) Redux and state middleware, and gRPC interceptors. store.dispatch passes an action through middleware, such as thunk, saga, and logger, before the reducer. gRPC interceptors do the same for RPCs, on both the client and the server.
8. Variants
| Variant | What it looks like | Notes |
|---|---|---|
| Classic chain | linked handlers, one handles and stops | approvals, escalation, "who can take this?" |
| Pipeline / middleware | a list plus next(), round-trip | the dominant modern form |
| All-handlers chain | every link runs, none short-circuit | validators collecting every error |
| Filter chain | links may transform the request or response | compression, sanitization |
| Conditional chain | links mounted per route, tenant, or environment | avoids per-request checks |
| Dynamic chain | built from configuration at runtime | plugin systems, per-tenant policy |
| Branching chain | a link routes to one of several sub-chains | Envoy filter chains, complex routers |
| Bidirectional | in-phase and out-phase per link | timing, error boundaries, transactions |
| Fail-fast vs collect-all | stop at the first failure, or accumulate them | validation wants collect-all |
Fail-fast versus collect-all is a genuine product decision, not a technicality. A security chain must fail fast, because you do not want to do work for an unauthorized request. A validation chain should usually collect all the errors, because returning "email is invalid", and then after the user fixes it "password too short", and then "name required", is a hostile experience. It is the same pattern with the opposite short-circuit policy, so decide it consciously per chain and write it down.
9. Where you already use it
| What you have used | The ordered line of handlers |
|---|---|
Express middleware and next() | log, then parse, then check the login, then handle |
try / catch up the call stack | each level may handle the error or let it keep rising |
| A click on a nested element | the innermost element gets it, then its parent, then its parent |
| Firewall rules | each rule in order may accept, reject, or say nothing and pass on |
| The steps of a build pipeline | any step can stop the whole run |
try/catch is the version built into the language, and it is worth naming as this pattern because most people never think of it that way. Throw an error inside a function and the runtime looks for a catch there. No catch? It looks in the function that called it. Still nothing? It keeps climbing. The first catch that matches handles the error and the climb stops. If nobody handles it, the program crashes.
Every ingredient is present. There is an ordered line of possible handlers, which here is the call stack. Each one either handles the request or passes it along. The thrower has no idea who will catch it, which is exactly why you can write a function that throws without knowing anything about the program that will use it.
10. Ways to get it wrong
Forgetting to call
next(). The request hangs with no error.The fix: a dev-mode timeout that names the offending middleware, plus a lint rule for a middleware whose path neither responds nor continues.
Calling
next()after responding. "Headers already sent".The fix: a
respondedflag, and alwaysreturnafter responding.Calling
next()twice. The rest of the chain runs twice, producing duplicate writes.The fix: the
lastCalledguard.No terminal handler. Unhandled requests vanish.
The fix: a catch-all last link, and throw on exhaustion where the chain must be handled.
The wrong order. Cache before authorization (a data leak), authorization before authentication, the error boundary not outermost.
The fix: document the constraint table and test it.
Implicit context coupling. Layer B needs
ctx.userfrom layer A, with nothing enforcing it.The fix: progressively typed contexts, or explicit runtime assertions.
Fat middleware. One layer doing authentication and logging and parsing.
The fix: one concern per link, because the composability is the whole point.
Business logic in the chain. Domain rules smeared across middleware, unreachable from a background job or a CLI.
The fix: the chain handles cross-cutting protocol concerns; the domain logic lives in the handler and the domain.
Expensive links early. A database lookup before a cheap signature check.
The fix: order by cost multiplied by rejection rate.
Long chains, undebuggable. Twenty layers with no way to see which one responded.
The fix: per-link tracing spans, and a debug header listing the layers traversed.
- Swallowing errors mid-chain. A
catchthat neither handles nor re-throws.
The fix: one error boundary, outermost; every other layer uses finally, not catch.
- Using a chain for two checks. Ceremony with no payoff.
The fix: two if statements.
11. Chain of Responsibility compared with its neighbours
| Compared with | The difference | Choose Chain when |
|---|---|---|
| Decorator | a Decorator wraps one component and keeps its interface, and every layer runs. A Chain is an ordered list where a link may stop the traversal | the steps are peers that may reject, and the set varies by context |
| Observer | Observer notifies all subscribers, order does not matter, nothing short-circuits. A Chain is ordered and may stop | order matters and a step may abort |
| Command | a Command is what to do, held as a value. A Chain is who or what processes a request | the concern is routing and layering, not storing the action |
| Strategy | Strategy picks one algorithm. A Chain applies several steps in order | multiple steps apply, not one choice |
| Composite | Composite is a tree of parts. A chain is a sequence, though a link may branch to a sub-chain | the structure is linear |
| Pipeline (data) | a data pipeline transforms values with no short-circuit | any stage may terminate processing |
Chain versus Decorator is the one to be exact about, because middleware genuinely is both, and the honest answer earns credit. Structurally they are almost identical, since each layer wraps the rest. The difference is intent and semantics. A Decorator adds behaviour to one specific component while keeping its interface, and it delegates inward — a decorator that refuses to call the thing it wraps is unusual. A Chain is an ordered list of peer handlers where not passing along is a normal, expected outcome, because that is how a 401 happens, and the composition varies per request path. The practical phrasing: "middleware is Chain of Responsibility implemented as a stack of decorators — the wrapping is Decorator's mechanism, and the ordered, short-circuitable, per-route composition is Chain's intent."
12. Interview calibration
The 45-second answer, in the order you would say it:
Chain of Responsibility passes a request along an ordered series of handlers, each of which can handle it and stop, change it and continue, or pass it straight through. The original version finds the one handler that can deal with the request, and an approval hierarchy is the natural example. The form that took over the industry is the pipeline, meaning middleware, where
next()is the rest of the chain, so every layer also gets control on the way back out, which is what lets one layer time the whole request or catch an error from below.The value is that cross-cutting concerns become independently testable units, composed per route as data, instead of twenty guard-clause lines duplicated across forty handlers. The order of the list is the security and performance policy — authenticate before authorize, authorize before cache or you leak one user's data to another, cheap rejections first — so I document those requirements and turn them into tests.
The two failure modes I design against are a link that forgets to call
next(), which hangs the request silently, and no terminal handler, so unhandled requests vanish; and I type the context progressively so that 'authorize needs authenticate before it' is a compile error rather than a production incident.
Follow-up questions, with the seed of each answer:
- "Chain versus Decorator?" — Same wrapping mechanism, different intent. Decorator augments one component and always delegates; a chain is ordered peers where not delegating is normal, composed per context.
- "What if nobody handles it?" — That is the original's admitted hazard. Always add a terminal handler, and throw on exhaustion where handling is mandatory.
- "How do you manage ordering?" — Write the constraint table, encode it as tests, and prefer progressively typed contexts so violations are compile errors.
- "How do you debug a twenty-layer chain?" — Per-link tracing spans, a debug header listing the layers traversed, and a dev-mode timeout naming the layer that neither responded nor continued.
- "Performance?" — Every request pays for every link, so order by cost multiplied by rejection rate, and mount conditionally per route instead of checking inside each link.
- "Fail fast or collect all?" — Security fails fast; validation should collect all errors, because fixing one field at a time is a hostile experience. Decide it per chain.
Recall
- Chain of Responsibility means a request travels an ordered list of handlers, and each one can handle it and stop, change it and continue, or pass it through. The trigger is fifteen guard-clause lines duplicated across forty handlers, with cross-cutting concerns whose order is implicit.
- There are two forms. The original finds the one handler eligible to deal with the request, which fits approvals, escalation, and "try parsers until one accepts". The pipeline passes through all of them, each contributing, any able to abort — and crucially, the response travels back out through every layer via
next(), which is where timing, error boundaries, transactions and response changes live. nextis the rest of the chain, as a closure. Not calling it is the short-circuit — there is no "handled" flag. Thetry { await next() } finally { … }idiom is the round trip.- The order of the list is the security and performance policy. Authenticate, then authorize, then cache inside authorization, because caching before authorization leaks one user's data to another. The error boundary is outermost, request ID comes before logging, rate limiting comes before expensive work, and cheap, high-rejection links come first. Write the table down and turn it into tests.
- The two silent failures: a middleware that forgets
next(), so the request hangs with no error (add a dev-mode timeout that names it), and no terminal handler, which is the original's admitted hazard, since nothing guarantees the request is handled (always add a catch-all, or throw on exhaustion). Also guard the doublenext(), which runs the rest of the chain twice, and respond-then-continue. - The shared mutable context is the usability weakness. Prefer progressively typed contexts, so
authorizerequiresAuthenticated<Ctx>and the ordering becomes a compile error; otherwise assert the dependency explicitly at the top of the dependent link. - Versus Decorator: same wrapping mechanism, different intent. A decorator augments one component and always delegates; chain links are ordered peers where not delegating is normal and the composition varies per route. Middleware is "Chain implemented as a stack of decorators".
- Fail-fast versus collect-all is a product decision: security fails fast; validation should collect every error.
Self-test: What is next, mechanically, and what does not calling it mean? Name four ordering requirements and the bug each one prevents. Why must a chain have a terminal handler? Give the Chain-versus-Decorator distinction in one sentence. How do you make "authorize requires authenticate" a compile error?
Quiz Bank
FoundationalShow how Chain of Responsibility is derived from a request handler full of guard clauses, and explain both forms of the pattern.
The naive starting point is nested if statements in one function, which is fine for two checks used in exactly one place.
The force is that a request has to pass through several independent steps in a defined order, each able to handle it and stop, change it and continue, or pass it through, and the set of steps varies per context.
What the inline version costs. The cross-cutting concerns are copied into every handler where they drift apart, so one endpoint forgets rate limiting and another checks roles before authentication, giving anonymous requests a confusing 403. The order is implicit in the line numbers and easy to get wrong in security-relevant ways. The concerns cannot vary per route without copying the whole block. Each concern is impossible to test on its own, because testing "does rate limiting fire?" needs a full valid request. Adding a concern means editing forty files. And the seven early returns make it easy for a newly inserted check to silently bypass the ones after it.
Drawing the line: which steps, how many, and in what order varies; the shape of a step — (request, next) → response — is fixed, and the order becomes data, which is the deeper win, because it turns something implied by line numbers into something you can read, reorder, and test.
When the choice is made: at composition time, since the chain is assembled at startup or per route.
The two forms. The original has handlers linked together, each with an eligibility rule, and exactly one handles the request while the rest pass it along — expense approval by amount, support escalation from level one to two to three, trying parsers until one accepts a file. The natural question is "who can take this?". The pipeline, or middleware, passes the request through all the links, each contributing something, and any of them can abort — and next() gives each layer control on the way back out, so a single layer can time the whole downstream chain, catch its errors, or rewrite its response. The natural question is "what should be applied to this?". The pipeline form dominates modern software.
What it costs: no guarantee that anybody handles the request, which needs a terminal handler; harder debugging through many frames; invisible ordering requirements; a per-request cost of traversing every link; and a shared mutable context that couples layers implicitly.
FoundationalIn middleware, what is `next` mechanically, what does omitting it mean, and what are the three ways to misuse it?
Mechanically, next is a closure that represents the rest of the chain. In compose, each layer is invoked as fn(ctx, () => dispatch(i + 1)), so the callback closes over this layer's index. Calling it runs everything after this point and returns a promise that resolves once the remainder — including the unwinding of every deeper layer — has finished. That single line is the whole mechanism, and two consequences follow from it.
First, code before await next() runs on the way in, and code after it runs on the way out, in reverse order, which is why try { await next() } finally { … } gives you timing, error boundaries, transaction commit and rollback, and cleanup that all cover the entire downstream chain.
Second, not calling next() is the short-circuit. There is no "handled" flag and no special return value. A layer answers a request simply by responding and not invoking the rest of the chain.
There are three ways to misuse it. First, forgetting to call it on some path: the request neither completes nor continues, so it hangs until the client times out, with no error and no log. This is the single most confusing middleware bug, and the fix is a development-mode timeout that logs "middleware X neither responded nor called next()", naming the culprit immediately. Second, calling it twice: the rest of the chain runs twice, producing duplicate database writes, duplicate outbound emails, and a "headers already sent" error whose stack trace points at the innermost layer rather than the guilty one. The fix is the lastCalled guard, which rejects the second call with a clear message, and every serious library, including Koa and Express, has this check. Third, responding and then calling it: the layer writes a response and continues, so a downstream layer writes another, giving the same "headers already sent" symptom. The fix is a responded flag checked before any write, plus the discipline of returning immediately after responding.
There is a fourth, subtler misuse: swallowing errors around it. A layer that wraps next() in a catch without re-throwing hides failures from below and returns a success response for work that actually failed. Only the outermost error boundary should catch; every other layer uses finally.
AppliedGive the ordering requirements for a production HTTP middleware chain, with the bug each prevents, and explain how to make ordering violations impossible rather than merely documented.
The order of a chain is its security and performance policy. Here are the requirements, each with the failure it prevents. The error boundary must be outermost, or an exception in a deep layer becomes an unhandled rejection and a 500 with no body. Request-ID assignment must come before logging, or log lines lack the correlation ID and a production incident cannot be traced across services.
Timing must sit outside the work it measures, or it reports a fraction of the true latency and your p99 dashboard is quietly wrong. Authenticate before authorize, because roles come from the authenticated user; reversed, an anonymous request either gets a confusing 403 instead of a 401 or, far worse, an authorization check runs against undefined and passes.
Authorize before cache, and this is the one that is an actual breach: a cache keyed by URL and placed before authorization will serve one user's admin report to an anonymous visitor. Rate limit before the expensive work, since the entire purpose is to avoid that work, so a limiter placed after the database query costs as much as the endpoint it protects.
Parse the body before validating it, since validating an unparsed stream fails meaninglessly. CORS and preflight early, since an OPTIONS preflight must not hit authentication, or browsers report opaque CORS errors. Compression outermost among the response writers, since it must see the final bytes. And the transaction inside authentication and authorization but outside the handler, or you open a database transaction, and take locks, for requests you are about to reject with a 403.
Making violations impossible rather than merely documented has three levels. The first is executable documentation: turn each requirement into a test against the real production chain, so "the cache is never consulted for an unauthenticated request" is a one-line regression test that outlives everyone who remembers why the order matters. This is the minimum. The second is runtime assertions: the dependent layer asserts its precondition at the top, such as if (!ctx.user) throw new Error("authorize requires authenticate before it"), which is one line and converts a silent misconfiguration into a loud failure on the first request rather than a subtle authorization bypass. The third, and strongest, is progressive context typing: authenticate transforms Ctx into Authenticated<Ctx> by narrowing the type it hands to next, and authorize accepts only Authenticated<Ctx>, so composing them in the wrong order does not compile. Typed frameworks such as tRPC, Hono, and NestJS interceptors provide this, and where the framework does not, a hand-rolled composer with generic context transformation can.
The general principle worth stating is that an ordering requirement which lives only in a comment will eventually be violated by somebody reordering the list for readability, so push it into the type system if you can, into the test suite if you cannot, and into a runtime assertion always.
InterviewDesign the request pipeline for a multi-tenant SaaS API where tenants have different auth methods, rate limits, feature gates, data-residency rules, and optional custom transformations. Cover composition, isolation, and observability.
The core decision is that the chain is data, resolved per tenant, built once and cached — not a fixed list with if (tenant.x) scattered through every layer. A request's first job is to identify the tenant, from a subdomain, a path prefix, an API key, or a JWT claim, and that resolution has to be cheap and cached, because everything downstream depends on it and it runs on every request.
Composition has three segments. First, a fixed prologue that runs for everyone and cannot be tenant-configured: request ID, error boundary, timing, panic recovery, global per-IP rate limiting, and tenant resolution. This segment is a security boundary, so a tenant must never be able to disable it, which is why it is not part of the configurable chain at all. Second, a tenant-resolved middle: the authentication strategy (OIDC, SAML, API key, or mTLS, chosen as a Strategy by tenant config), tenant-scoped rate limits and quotas, feature gates, data-residency routing, and any tenant-specific transformations. This chain is compiled once per tenant configuration version and cached by a key of tenant ID plus config version, so the per-request cost is one map lookup rather than rebuilding a pipeline. A config change bumps the version, and the next request compiles a fresh chain, with no restart and no per-request conditionals. Third, a fixed epilogue: response serialization, compression, and the audit write.
Isolation is what the question is really testing. Cache keys must always include the tenant ID, preferably prefixed so that a missing tenant ID is a malformed key rather than a shared one, because the cache-before-authorization leak becomes a cross-tenant leak here, which is the worst incident this system can have.
Rate limits are per tenant and per key within a tenant, with a global backstop so one tenant cannot exhaust shared capacity, and a concurrency limit as well as a rate limit, since a tenant with fifty slow requests harms others even within its rate budget. Data residency means the chain must route to a regional data plane before any data access, and must refuse rather than fall back if the region is unavailable, because a fallback that silently serves EU data from a US replica is a compliance breach, so this link fails closed. Custom transformations are the dangerous feature: tenant-supplied logic must be sandboxed with CPU and memory budgets and a hard timeout, must not be able to see other tenants' data or make arbitrary network calls, and must be versioned and revertible. If the product does not truly need arbitrary code, prefer a declarative transformation spec, such as a mapping DSL, for a vastly smaller attack surface.
Failure semantics. If the tenant config is unavailable, serve from a cached last-known-good config and alarm, because failing closed on a config-store blip would take down every tenant, so this specific link fails open to the last good state rather than open to no policy. If a tenant-specific link throws, the error boundary returns a 500 with the tenant and link name in the log, and a circuit breaker disables that tenant's custom link after repeated failures rather than letting it fail every request.
Observability is mandatory here. Every log line, metric, and trace span carries the tenant ID and config version. Dashboards are per tenant and aggregate, because "the API is slow" is almost always "one tenant is slow". Per-link tracing spans show which layer costs the time for which tenant. And a debug endpoint returns the resolved chain for a tenant, listing the link names in order, which turns "why is this tenant behaving differently?" from an investigation into a lookup.
The summary sentence: make the chain per-tenant data compiled once per config version and cached, wrap it in a fixed prologue and epilogue that tenants cannot influence, put the tenant ID in every cache key, rate limiter, log line and trace span, fail closed on residency and open-to-last-known-good on config, and sandbox anything tenant-supplied, because in multi-tenancy the pipeline's ordering bugs stop being bugs and become breaches.
StaffRequests in a service traverse 30 middleware layers; p99 latency is 400ms with only 40ms in the handler, and nobody can say where the time goes. Diagnose and fix, and prevent recurrence.
Diagnosis first, and the first finding is usually not "middleware is slow" but "the chain does work it should not, for requests that do not need it".
Step one is to measure per link, because you cannot fix what you cannot attribute. Wrap the composer so every link is instrumented with its own tracing span and a duration histogram tagged by link name and route. This is a one-time change in one place, which is the payoff of having the chain as data, and it usually produces the answer within an hour of production traffic. Expect to find three or four links owning most of the time.
Step two is to classify what you find. In practice the recurring culprits are: I/O in a middleware that could be cached, such as an auth layer that verifies a JWT against the identity provider on every request instead of validating the signature locally and caching the JWKS, or a permission lookup hitting the database per request; links running for requests that do not need them, such as a multipart parser, an ETag calculator, or a locale negotiator executing on every request including health checks and JSON POSTs; sequential I/O that could be concurrent, such as three independent lookups for user, tenant, and feature flags in three sequential layers, each awaiting the previous; expensive links placed before cheap rejections, so requests destined for a 401 still pay for body parsing and rate-limit lookups; and accidental blocking work, such as a synchronous crypto operation, a JSON re-serialization per layer, or a regex with catastrophic backtracking in a sanitizer.
Step three is to fix by cost multiplied by probability, in this order. First, reorder: cheap, high-rejection links first, so signature and token-format checks come before any database or network call. This is free, and it is often the largest single win. Second, mount conditionally per route rather than checking inside each link, by building per-route chains at startup so the multipart parser physically does not exist on JSON routes. Third, cache the I/O: the JWKS, tenant config, permission sets, and feature flags, with short TTLs and stale-while-revalidate so that a cache miss does not spike latency. Fourth, parallelize independent lookups by merging those layers into one that fetches concurrently, which is a legitimate reason to reduce the link count. Fifth, delete links: in a thirty-layer chain there are typically several that are dead, duplicated (two logging layers), or could be one.
Step four is a hard rule to prevent recurrence. Every link declares a latency budget, CI runs a benchmark asserting the composed chain's overhead stays under a threshold, and a link exceeding its budget in production alerts with its own name. Add a "no new I/O in middleware without a cache and a budget" review rule, and a per-route chain snapshot test so a link cannot be silently added to every route.
Step five is the architectural question worth raising. Thirty layers usually means the chain has absorbed things that are not cross-cutting protocol concerns — business rules, per-feature checks, response shaping — which belong in the handler or the domain, where they run only when relevant and are reachable from background jobs and CLIs too. Moving them out reduces both latency and the chain's conceptual weight, and it is the fix that lasts.
What to report: the per-link latency table before and after, p99 by route, the number of links per route (thirty down to, say, twelve on hot routes), and the handler's share of total time as the health metric, since the goal is that the pipeline is a small, predictable constant rather than a majority of the request.
The summary sentence: instrument every link individually first, then reorder by cost multiplied by rejection rate, mount per route instead of checking per request, cache the I/O the auth and config layers repeat, merge independent sequential lookups, and move everything that is not a cross-cutting protocol concern out of the chain, then hold it with per-link budgets enforced in CI, because a pipeline nobody measures always grows.
Flashcards
FlashChain of Responsibility in one line
A request travels an ordered list of handlers; each can handle it and stop, change it and continue, or pass it through. The order of the list is the policy.
FlashThe two forms
The original finds the ONE eligible handler (approvals, escalation). The pipeline / middleware passes through ALL, each contributing, any able to abort — and the response returns back out through every layer.
FlashWhat is next()?
A closure representing the rest of the chain. Code before it runs on the way in, code after it on the way out. NOT calling it is the short-circuit — there is no handled flag.
FlashChain ordering requirements
Error boundary outermost; request ID before logging; authenticate before authorize; authorize before cache (or you leak one user's data to another); rate limit before expensive work; cheap rejections first.
FlashChain: the two silent failures
A link that forgets next() hangs the request with no error. No terminal handler means unhandled requests vanish. Also guard the double next() and respond-then-continue.
FlashChain versus Decorator
Same wrapping mechanism, different intent. Decorator augments one component and always delegates; chain links are ordered peers where not delegating is normal, composed per route.
Scenario Drill
DrillDesign the processing pipeline for an email-receiving service: a message arrives via SMTP and must pass spam scoring, virus scanning, DKIM and SPF verification, size and attachment policy, per-recipient rules, auto-responders, and delivery to a mailbox — with quarantine, bounces, and per-domain configuration. Show the chain and the hard parts.
The shape is three chains, not one, because SMTP forces decisions at different moments, and getting that wrong means either accepting mail you must then silently drop, or rejecting mail you should have accepted.
Chain one is the connection and envelope chain, during the SMTP conversation. It runs before the message body is transferred: IP reputation and blocklist lookup, rate and connection limits per source, TLS requirements, an SPF check on the MAIL FROM, recipient existence, and per-domain acceptance rules. The links in this chain can reject cheaply and correctly with an SMTP error code, and that is the single most important property in the whole design, because rejecting during the conversation makes the sender responsible for notifying its user, while accepting and then discovering a problem forces you either to bounce, which creates backscatter spam if the sender was forged, or to silently drop, which loses mail. The engineering rule is: reject as early as the protocol allows, and never accept-then-drop. Order by cost multiplied by rejection rate, so an IP blocklist check that costs microseconds and rejects the majority of connections comes before everything.
Chain two is the message chain, after DATA. Now the body exists: size limits, MIME parsing (with depth and part-count caps, because a malformed nested MIME bomb is a classic denial-of-service), DKIM signature verification, DMARC evaluation combining the SPF and DKIM results, virus scanning, spam scoring, and attachment policy. The ordering requirements here are sharp.
Parse before anything that reads structure. Verify DKIM on the raw bytes before any transformation, because rewriting headers or normalizing line endings invalidates the signature — a mistake that makes every legitimate message fail authentication. Virus scan before anything that stores or forwards the content. Size limit before scanning, so a two-gigabyte message never reaches the scanner. Spam scoring is deliberately last of the analyzers, because it consumes the results of all the others, and a DMARC failure is a strong spam signal.
This chain is collect-all, not fail-fast, unlike a security chain, because you want every verdict — the spam score, the virus result, the authentication results — attached to the message, since the routing decision is a function of all of them and the user-visible headers must explain what happened. That is an explicit and unusual design choice worth stating.
Chain three is the per-recipient delivery chain. One message may have many recipients with different policies, so this chain runs per recipient over the shared analysis result: user filters and rules, folder routing, quota checks, forwarding, auto-responders, and the final mailbox write. Running analysis once and delivery N times is the efficiency that matters at scale, and it is why the two chains are separate.
The verdict is not binary; it is a routing decision. Reject (during SMTP), quarantine (accept, store, do not deliver, make it retrievable — the right answer for probable-but-uncertain spam), deliver to junk, deliver with a warning header, or deliver normally. Silent deletion is never an option, because a message that is accepted must be findable somewhere, or you will one day be unable to answer "where did my invoice go?".
The hard parts. Idempotency and retries: SMTP retries are constant, so message processing keyed by message ID must be idempotent, or a retried delivery double-delivers or fires the auto-responder twice. Auto-responder loops: two vacation responders will email each other forever, so the defences are mandatory — never auto-respond to a message with an empty return path or an Auto-Submitted header, rate-limit responses per sender pair, and add a loop-detection header — and this is exactly the kind of rule that must live in one link rather than being reimplemented. Timeouts inside the SMTP conversation: the whole message chain runs while the sender waits, so it needs a hard deadline, and a virus scanner that hangs must produce a temporary-failure response (a 4xx, "try again later") rather than a permanent rejection, because a 5xx on a transient failure destroys legitimate mail.
The 4xx-versus-5xx decision is the highest-stakes judgment in the pipeline and belongs to every link explicitly: infrastructure problems are 4xx, and policy decisions are 5xx. Per-domain configuration: each hosted domain has its own policy, so — as with the multi-tenant HTTP case — the chain is compiled per domain-config version and cached, with a fixed prologue the domain cannot disable. Re-deriving the order: any change to the chain requires re-checking the constraint table, especially DKIM-before-transformation and scan-before-store.
Observability. Every message carries a trace with per-link verdicts and durations. The operational dashboard is per-link rejection rate, so a sudden drop in the blocklist link's rejection rate means the list is failing open, which is a silent security regression. Plus scanner latency, quarantine volume, and false-positive reports from users, which are the only real accuracy signal.
The summary sentence: split into an envelope chain that rejects early while the sender is still responsible, a collect-all message chain that verifies DKIM on untouched bytes and gathers every verdict before routing, and a per-recipient delivery chain — with quarantine instead of silent deletion, 4xx for infrastructure and 5xx for policy, hard deadlines inside the SMTP conversation, and per-domain chains compiled from config — because in mail the ordering rules are not preferences: transform before you verify, and you break authentication for every legitimate sender.