Chain of Responsibility Pattern
1. What the Chain of Responsibility pattern is
The Chain of Responsibility pattern sends a request down a line of handler objects until one of them handles it. The sender doesn’t know which handler will respond, or how many handlers there even are. It hands the request to the front of the chain and lets the chain sort it out.
Each handler holds a reference to the next one and makes a local decision: can I handle this? If yes, it handles it and the request stops there. If no, it forwards the request to its successor. The flow is a straight line:
request → handler A → handler B → handler C → handled (or dropped off the end)
That structure buys one specific thing: the code issuing the request is decoupled from the code that fulfils it. The sender depends only on “the front of the chain,” not on the concrete handlers or the order they’re tried in. Adding or reordering a handler is a change to how the chain is assembled, not a change to the sender.
2. Why a chain beats a central dispatcher
Without the pattern, request routing collapses into one method that knows every handler. Take an expense-approval flow: a purchase under $1,000 needs a team lead, under $10,000 a manager, anything larger a director. The naive version is a cascade of if/else if:
public void approve(PurchaseRequest request) {
if (request.amount() <= 1_000) {
// team lead approves
} else if (request.amount() <= 10_000) {
// manager approves
} else if (request.amount() <= 100_000) {
// director approves
} else {
throw new IllegalStateException("No approver for this amount");
}
}This works, and for three fixed tiers it’s arguably fine. It stops being fine the moment the tiers change. Every new approval level is an edit to this one method, so it violates the open/closed principle: a class that should be closed for modification has to be reopened for every routing change. The method also has to know every approver and every threshold, which makes it the single place that has to change for any routing tweak. The branches can’t be reused or reordered either — a finance department that needs a different chain (say, an extra compliance check between manager and director) has to copy the whole method and mutate it.
Chain of Responsibility turns each branch into its own object. The routing logic that lived in one method is split across handlers that each know only their own rule, and the chain is assembled at runtime instead of hard-coded. A new tier is a new class plus one line at assembly time, and the existing handlers don’t change.
3. Writing one in Java
The pattern has two moving parts: an abstract handler that owns the “handle or forward” logic, and concrete handlers that supply the actual decision. The expense-approval flow makes both concrete.
3.1. The handler base class
The base class holds the link to the next handler and the forwarding logic. Subclasses only fill in two things: whether they can approve a given request, and what approving means.
public abstract class Approver {
private Approver next;
public Approver linkTo(Approver next) {
this.next = next;
return next;
}
public final void handle(PurchaseRequest request) {
if (canApprove(request)) {
approve(request);
} else if (next != null) {
next.handle(request);
} else {
throw new IllegalStateException(
"No approver for " + request.purpose() + " ($" + request.amount() + ")");
}
}
protected abstract boolean canApprove(PurchaseRequest request);
protected abstract void approve(PurchaseRequest request);
}Two design choices are worth calling out. First, handle is final: the forward-or-handle skeleton is fixed, and subclasses can’t accidentally break the chain by overriding it. They customise behaviour through the two abstract hooks instead. (This is the Template Method pattern doing quiet work inside a Chain of Responsibility.) Second, linkTo returns the handler it just linked to, which makes assembling the chain a one-liner in §3.2.
The else branch with no next is the part most naive implementations get wrong. A chain is not obligated to handle anything, so a request can run off the end with nobody acting on it. Here I throw, because an unapprovable purchase is a real error. The alternative is a silent no-op, which is worse — the caller thinks the request was handled. Decide explicitly.
The request itself is a plain immutable carrier:
public record PurchaseRequest(String purpose, double amount) {}3.2. Concrete handlers and assembling the chain
Each concrete Approver is small. It knows one threshold and nothing about its neighbours:
public final class TeamLead extends Approver {
private static final double LIMIT = 1_000;
@Override
protected boolean canApprove(PurchaseRequest request) {
return request.amount() <= LIMIT;
}
@Override
protected void approve(PurchaseRequest request) {
System.out.println("Team lead approved: " + request.purpose());
}
}Manager and Director are the same shape with a higher LIMIT and a different log line. None of them references another handler, which is the point: a handler is reusable in any chain, at any position. That’s the payoff.
Assembly is where the chain’s shape is decided, and it’s the only place that knows the full order:
Approver lead = new TeamLead();
lead.linkTo(new Manager()).linkTo(new Director());
lead.handle(new PurchaseRequest("Standing desk", 450)); // team lead
lead.handle(new PurchaseRequest("Team offsite", 8_000)); // manager
lead.handle(new PurchaseRequest("Build server fleet", 60_000)); // directorBecause linkTo returns its argument, the links chain left to right and read in chain order. Swapping in a different chain (a compliance check before the director, a per-department order) is an edit here and nowhere else.
3.3. Pure and impure chains
The approval chain above is a pure Chain of Responsibility: exactly one handler acts, and the request stops the moment it’s handled. That’s the textbook form, but it’s not the only one.
In an impure chain, every handler sees the request and independently decides whether to act, and the request always runs to the end. Severity-filtered logging is the canonical example: a record at WARNING level should reach the console, the file, and the on-call email, while a DEBUG record reaches only the console. The base class drops the else:
public abstract class Logger {
private Logger next;
private final Level threshold;
protected Logger(Level threshold) {
this.threshold = threshold;
}
public Logger linkTo(Logger next) {
this.next = next;
return next;
}
public final void log(Level level, String message) {
if (level.ordinal() >= threshold.ordinal()) {
write(message);
}
if (next != null) {
next.log(level, message);
}
}
protected abstract void write(String message);
}The structural difference is one keyword. The pure chain forwards in an else, so handling and forwarding are mutually exclusive. The impure chain forwards unconditionally, and acting and forwarding become independent. Both are Chain of Responsibility; they just answer “does the request stop here?” differently. When you reach for the pattern, decide which one you mean up front, because the two behave nothing alike the moment more than one handler matches.
4. Where the pattern appears in the JDK and frameworks
Chain of Responsibility runs throughout the Java world. Servlet filters, java.util.logging, and Spring Security’s filter pipeline are all chains. It’s a pattern you’ve used without naming it, and it shows up wherever a request passes through an open-ended set of stages:
- Servlet filters. A
jakarta.servlet.Filterreceives the request plus aFilterChainand decides whether to callchain.doFilter(request, response)to pass control on. Authentication, compression and logging filters are links; each can short-circuit the chain by not forwarding. This is an impure chain with an opt-out. java.util.logging. ALoggerpasses eachLogRecordto its own handlers and then up to its parent logger, which has its own handlers, and so on toward the root. Severity thresholds at each level decide who writes. It’s the impure logging chain from §3.3, built into the JDK.- Spring Security.
FilterChainProxyruns an ordered list of security filters per request. Each filter inspects the request, optionally acts (reject, authenticate, set headers), and forwards. Reconfiguring security is reordering the chain. - Exception propagation. A thrown exception travels up the call stack until a
catchblock claims it, or falls offmainunhandled. It’s a degenerate pure chain where the links are stack frames and the language runtime walks them for you.
Pattern recognition cuts both ways here. Spotting it in existing code tells you how to extend that code (add a link, don’t edit a dispatcher), and naming it in your own design tells the next reader what to expect.
5. When not to use it
Chain of Responsibility earns its place when the set of handlers is genuinely open-ended and the sender shouldn’t know them. Outside that case, it adds indirection that buys nothing. A few smells:
- The dispatch key is known up front. If you’re routing on a fixed value (an enum, a message type, a status code), a
Map<Key, Handler>is an O(1) lookup that says exactly what it does. Walking a chain to find the one handler whosecanApprovereturns true is slower and murkier than indexing a map. Reserve the chain for when handlers match on a predicate, not a key. - No handler is guaranteed to act. A pure chain can drop a request off the end. That’s a feature when “nobody handled it” is meaningful, and a latent bug when it isn’t. If every request must be handled, either enforce it (throw at the end, as in §3.1) or use a structure that can’t silently miss.
- The execution path is hard to trace. The sender’s decoupling from the handlers is the whole point, but it also means a stack trace shows you
handle → handle → handlewith no indication of which link did the work or why an earlier one passed. Long chains are genuinely harder to debug than a flatswitch. Keep them short. Log the link that acts. - Order is load-bearing but invisible. The chain’s behaviour depends on assembly order, and that order lives in one assembly site that’s easy to overlook. If reordering two handlers changes the result, that coupling deserves a comment at the assembly site, not a surprise in production.
The honest rule of thumb: use a chain when handlers are added and removed independently and match on conditions rather than a key. When the routing is a fixed, finite mapping, a map or a switch is the simpler tool. Simpler wins.