- Low Level Design
- /
- Chain of Responsibility Pattern
Course content
Chain of Responsibility hands a request through a chain of handlers, one at a time, until one of them takes ownership and handles it. Each handler decides one thing: can I handle this, and if not, who is next? The sender does not know which handler will end up doing the work, only that the chain will route the request somewhere. The chain itself is a list of handler objects, each holding a reference to the next, terminated by a handler that can answer everything (or by a rejection if nobody can).
The shape is mechanical: an abstract Handler with a next field and a setNext(Handler) method, plus subclasses that override the actual handle(...) method. Each subclass checks a condition ("is this within my authority?", "is this my log level?", "is this URL my responsibility?"): if yes, it handles; if no, it forwards to next. If next is null, the chain has been exhausted, and the conventional behaviour is to either reject explicitly or do nothing. The page starts from the version every codebase grows first: the ladder of else-ifs.
ApprovalService.approve knows the entire org chart: every role, every spending limit. The role-specific knowledge is hard-coded inside a method that has no business holding it. Three problems follow.
First, the OCP violation is direct: every change to the approval rules requires editing this method. New role? New else if. Limit changes? Edit the constants. Different chains for different teams? You either spawn another method (approveExpedited) that copies the whole ladder or fork the codebase. There is no extension story; only modification.
Second, each branch is a small, independent rule ("Manager can sign off up to $1k") wedged into a shared method. The rule cannot be tested in isolation: to test the Manager case you have to instantiate ApprovalService and call approve with a $500 request and assert the result is the string "Manager". The rule cannot be reordered without changing source code. The rule cannot be reused (a different approval service that uses the same Manager rule but a different VP rule has to copy the branch).
Third, the chain is invisible. Looking at ApprovalService you cannot tell what the actual organisational hierarchy is: the order is implicit in the order of else if branches. There is no first-class "chain" object you can inspect, log, or reconfigure at runtime. The dispatcher and the chain are the same code, and that conflation is the smell.
What this costs in production
Finance hired a CFO who could approve up to a million, so an engineer added one more else if to ApprovalService.approve. The new branch was placed after the reject case, so it never ran, and every seven-figure purchase silently came back REJECTED. Because the approval ladder lived in one method with no test per rule, the gap was only caught when a vendor called asking why a signed deal had stalled.
The approval ladder is one instance of dispatch knowledge living in the dispatcher instead of the handlers. In review, the symptoms look like these.
| Smell | What it looks like | Why CoR is the answer |
|---|---|---|
| A long if/else ladder dispatching on a numeric or categorical attribute | if (amount <= 1000) approveByManager(); else if (amount <= 10000) approveByDirector(); else approveByVp(); | Each branch is a handler waiting to be extracted; the ladder is the chain inlined |
| Adding a new branch ("now we have a CFO who handles >$1M") requires editing the dispatcher | The if/else lives inside ApprovalService and breaks the Open-Closed Principle | A new handler should be a new class added to a chain configuration; the existing dispatcher should not move |
| The dispatcher needs information that belongs to the handlers themselves | ApprovalService imports each role's spending limit constant | The limit (<= $1000) is a property of Manager; the dispatcher should not own that knowledge |
| Multiple slightly-different orderings of the same handlers | approveStandard() does Manager, Director, VP; approveExpedited() does Director, VP, Manager | The chain is a configuration; multiple chains are multiple configurations of the same handler classes |
| Cross-cutting validation/auth logic copy-pasted at many entry points | Every controller method does isAuthenticated, then isAuthorised, then rateLimit, then handle | These are handlers in a chain (a Servlet filter chain in fact); each entry point reuses the same chain instead of re-implementing it |
| A try/catch tower walking up the call stack to find someone who can handle the exception | Exception bubbling: each frame catches what it can, re-throws the rest | Exception handling IS Chain of Responsibility; each frame is a handler, the call stack is the chain |
Three things changed and they unlock the Open-Closed Principle.
Each role is now its own class with one method. Manager.approve knows the manager's limit and nothing else. The class is independently testable, independently reusable, and independently changeable. Limit goes up to $1,500? You edit one constant in Manager and ship; no other class moves.
The chain itself is data. chain.setNext(new Director()).setNext(new Vp()) is the wiring that defines an org's approval hierarchy; a different org would call setNext in a different order or insert a CFO between Director and VP. Multiple chains can exist side by side (standard vs. expedited approval, different limits per tenant); each is just a different sequence of the same Approver classes. The org chart became a runtime structure, not a hardcoded else if ladder.
Adding a new role is purely additive. class Cfo extends Approver { ... } is one new file; chain.setNext(new Cfo()) adds it to the chain. None of the existing approvers, and none of the calling code, change. The Open-Closed Principle is satisfied: the system is open to extension (new approvers, new chain orderings) but closed to modification (no existing class changes when an approver is added).
The forwardOrReject helper deserves attention. Without it, every subclass would have an if (next != null) ... line, duplicated. By pushing the forward-or-reject decision into the abstract base, every subclass becomes a single conditional: "in my range? handle it; otherwise, forward." That is the cleanest the pattern can get.
With the refactor in hand, it is worth pinning down what CoR is not. The differences are about who decides and what happens to the request.
CoR vs. Decorator: same shape (each object holds the next one), different intent. A Decorator's whole job is to augment: it does its own work AND calls the wrapped object, so every decorator runs on every request. CoR's job is to route: at most one handler does the work, and the request stops there; the handlers downstream of the one that handled never run. If every layer fires on every call, you have a Decorator chain; if exactly one layer claims the request, you have a CoR chain. Servlet Filters blur this line because each filter both wraps the next AND decides whether to pass control along, which is why the API is sometimes described as both.
CoR vs. Strategy: Strategy gives the caller a single object whose algorithm varies. CoR gives the caller a chain of objects, each capable of handling some subset, and the chain decides who runs. With Strategy the caller picks; with CoR the chain picks. If the dispatch decision is "which algorithm should I use?" and the caller has the information to decide, you want Strategy. If the dispatch is "who should handle this?" and the answer depends on the request itself in a way the caller cannot easily decide, you want CoR.
CoR vs. Observer: both let multiple objects react to one event source, and the head count is the difference. An Observer subject notifies every registered listener; a chain stops at the first handler that claims the request. If everyone should see the event, you want Observer; if exactly one party should take ownership, you want the chain.
CoR vs. an if/else ladder: a long if (amount <= 1000) { manager.approve(...); } else if (amount <= 10000) { director.approve(...); } else ... is the procedural equivalent of CoR. It works for two or three branches in one place; it falls apart when the branches need to be configured (different chains for different organisations), when new approvers should slot in without touching the existing code, or when the same chain is used in many places. CoR is the OO refactor of the if/else ladder: each branch becomes its own object, and the order is data, not code.
The Servlet Filter chain is the textbook CoR in Java: a request walks through every registered filter and reaches the target servlet only if each one chooses to call chain.doFilter(...), and any filter can short-circuit by returning without that call. Spring Security's FilterChainProxy layers its whole authentication stack this way, and its AuthenticationManager tries each AuthenticationProvider in turn until one supports the credentials, the same pattern at a smaller scale. Spring MVC's HandlerInterceptor chain wraps controller invocation the same way: any preHandle returning false stops the request cold. OkHttp puts the pattern's name in its API, handing each Interceptor an Interceptor.Chain and letting it decide whether to proceed.
Logger hierarchies are CoR too. In Log4j and Logback, a log call walks up the logger tree (com.example.api.UserController, then com.example.api, then com.example, then root); at each level the configured appenders fire and the additivity flag decides whether to keep walking, which is why a level set on a parent logger affects its children. And exception handling is the language-level version in Java, Python, and C++ alike: the runtime unwinds the call stack frame by frame, each frame either catches the exception type or passes it up, and the uncaught-exception handler is the chain's rejection terminator.
Django middleware is Python's canonical chain. Each middleware receives the request plus the next callable (get_response) and can return a response early (the short-circuit) or pass the request along, and the MIDDLEWARE list in settings is literally the chain as configuration. WSGI middleware stacks compose the same way, and Python's logging module propagates records up the logger tree exactly like Log4j, with propagate = False as the additivity switch.
Beyond exceptions, the classic C++ sighting is GUI event propagation: in Qt, an event a widget does not accept bubbles up to its parent, parent by parent, until some ancestor handles it, a chain wired along the object tree. The generic C++ form is the same as everywhere else: an ordered container of handler objects, each given the chance to claim the request before it moves on.
| Pros | Cons |
|---|---|
| Adding a new handler is one new class; the Open-Closed Principle is satisfied | Multiple small classes can feel scattered; you need wiring code or configuration to see the actual chain |
| Each handler is independently testable: instantiate, call its method, assert the outcome | If no handler claims the request and the chain has no rejection-terminator, the request silently disappears (a debugging hazard) |
| Chain order is data: different chains for different environments, tenants, or feature flags without code changes | Order matters and is implicit; getting Manager and Director swapped does not crash, just routes differently, and the bug is silent |
| Sender is decoupled from handlers; it knows only the chain entry point, not which class actually handled the request | Stack traces grow one frame per handler; debugging a 6-stage chain is tedious |
| Plays well with framework conventions: Servlet filters, Spring Security filters, OkHttp interceptors all use this shape | Easy to reach for when a Strategy or a Map<key, handler> would be simpler; for finite, well-known dispatch use the simpler structure |
CoR pays off when the dispatch decision is not made by the caller, when the request itself, walking through a chain, finds the right handler. If the caller already knows which handler to invoke, you are looking at Strategy or a plain method call, not CoR. A 30-line if/else inside ApprovalService is a smell; a 4-line one with two branches is just an if/else, and turning it into a chain of two handlers is overkill.
The other failure mode is "every dispatch becomes a chain". When you have a finite, known set of types and the dispatch is trivial ("shape is a circle, square, or triangle; render it"), a Map<ShapeType, Renderer> is simpler, faster, and easier to read than a chain. CoR is for cases where the handler decision is conditional ("can this approver authorise this amount?"), not for cases where it is a simple key lookup. If your handlers' canHandle checks are all single equality comparisons, you have a map, not a chain.
Finally, watch for handlers that cannot refuse the request. If every handler in the chain handles every request, the chain is really a Decorator chain (each layer adds work and forwards); call it that, document it as that, and resist the temptation to insert handlers that always pass the request along. Conversely, watch for handlers that never handle anything but exist to do logging or instrumentation; those are also Decorators wearing a CoR coat. The pattern is sharper when handlers actually terminate the chain when they decide to act.
Three flavours of question recur. The first is design: "design an approval workflow", "design a logger with multiple handlers", "design a help-desk escalation system". The expected first move is to define the abstract handler with next and setNext, write each concrete handler as one class with one decision, and show the wiring code that builds the chain. The follow-up question is usually "how do you add a new role"; that is your cue to point out that adding a handler is one new class and one new line of wiring, and nothing else moves.
The second is comparison: "CoR vs. Decorator", "CoR vs. Strategy", "CoR vs. Observer". Decorator is the closest because of the same shape; the distinction is whether every layer fires (Decorator) or one layer terminates (CoR). Strategy is the caller's choice; CoR is the chain's choice. Observer is one-to-many fan-out (every observer fires on every event); CoR is one-of-many dispatch (one handler claims the event). Some frameworks (Spring Security, Servlet filters) blur Decorator and CoR; call this out and say which side of the line a given example sits on.
The third is implementation depth: "how do you signal that no handler claimed the request", "how do you handle priorities", "how do you make the chain dynamic". The exhaustion answer is by convention: return null, throw, or return a sentinel value ("REJECTED"); pick one and document it. The priority answer is order-of-insertion in the chain; if priorities are dynamic, you sort the list of handlers before dispatching. The dynamic-chain answer is to hold a List<Handler> (or a graph) instead of a linked next field and walk it explicitly; Servlet filter chains and Spring Security do exactly this because the order needs to be configurable at startup. Showing you know these variants signals that you have actually built CoR pipelines, not just read about them.
The companion exercise gives you an abstract Approver base class with next and setNext, a PurchaseRequest value object, and stubs for three concrete approvers (Manager, Director, Vp) with ascending spending limits. You implement approve on each so that small requests are handled by the first approver capable of authorising them and oversized requests are rejected when the chain is exhausted. The validator checks each approver in isolation, asserts the chain forwards correctly across two and three approvers, and confirms that an exhausted chain returns "REJECTED" rather than throwing.
Open the exercise: Chain of Responsibility: Build a Purchase Approval Chain.