Passing a request along a chain of handlers until one handles it — built through a request-validation pipeline, and how this is exactly the shape of Spring Security's own filter chain.
Published September 23, 2026
Rather than one class containing an if/else chain that checks every possible condition, Chain of Responsibility gives each check its own handler object, linked in sequence — each handler either processes the request and stops the chain, or passes it to the next handler.
abstract class RequestHandler {
protected RequestHandler next;
RequestHandler setNext(RequestHandler next) { this.next = next; return next; } // returns next — enables chaining the setup itself
final void handle(Request request) {
if (!process(request)) return; // this handler rejected the request — stop the chain here
if (next != null) next.handle(request); // passed — hand off to the next link
}
protected abstract boolean process(Request request); // true = continue chain, false = reject/stop
}
class AuthCheck extends RequestHandler {
protected boolean process(Request request) {
if (!request.isAuthenticated()) { reject(request, 401); return false; }
return true;
}
}
class RateLimitCheck extends RequestHandler {
protected boolean process(Request request) {
if (isRateLimited(request)) { reject(request, 429); return false; }
return true;
}
}
class SchemaCheck extends RequestHandler {
protected boolean process(Request request) {
if (!isValidSchema(request)) { reject(request, 400); return false; }
return true;
}
}
RequestHandler chain = new AuthCheck();
chain.setNext(new RateLimitCheck()).setNext(new SchemaCheck());
chain.handle(incomingRequest); // flows through Auth -> RateLimit -> Schema, stopping at the first rejection
Each handler is independently testable, independently addable/removable, and doesn't need to know anything about the other checks in the chain — AuthCheck has no idea RateLimitCheck even exists, it just calls next.handle() if it passes.
If this looks familiar, it should — it's structurally identical to the servlet filter chain covered in synchronized and Locks' broader context and, more directly, Spring Security's own SecurityFilterChain (see Spring Security Overview): each filter either processes the request and calls chain.doFilter() to pass it along, or short-circuits by writing a response directly and never calling doFilter(). Recognizing "this is Chain of Responsibility" the moment you see a filter/middleware/interceptor pipeline is a genuinely useful pattern-recognition shortcut — it tells you immediately how ordering matters, how a stage can short-circuit, and how to add a new stage without touching existing ones.
Chain of Responsibility shows up in two shapes, and naming which one you're building avoids confusion:
record ExpenseClaim(String employeeId, BigDecimal amount, String reason) {}
abstract class Approver {
private Approver next;
Approver linkWith(Approver next) { this.next = next; return next; }
final Decision approve(ExpenseClaim claim) {
if (canApprove(claim)) return decide(claim);
if (next == null) return Decision.rejected("No approver can authorise " + claim.amount());
return next.approve(claim);
}
protected abstract boolean canApprove(ExpenseClaim claim);
protected abstract Decision decide(ExpenseClaim claim);
}
class TeamLead extends Approver {
protected boolean canApprove(ExpenseClaim c) { return c.amount().compareTo(new BigDecimal("50000")) <= 0; }
protected Decision decide(ExpenseClaim c) { return Decision.approvedBy("team-lead"); }
}
class Director extends Approver {
protected boolean canApprove(ExpenseClaim c) { return c.amount().compareTo(new BigDecimal("500000")) <= 0; }
protected Decision decide(ExpenseClaim c) { return Decision.approvedBy("director"); }
}
class Cfo extends Approver {
protected boolean canApprove(ExpenseClaim c) { return true; } // the end of the chain
protected Decision decide(ExpenseClaim c) { return Decision.approvedBy("cfo"); }
}
Approver chain = new TeamLead();
chain.linkWith(new Director()).linkWith(new Cfo());
Note the explicit handling when no one in the chain can handle the request. Forgetting it is the most common bug in this pattern: the request silently disappears.
Linked next pointers are the textbook form. In practice, chains are usually a list that a small runner walks, which makes ordering visible and configurable in one place:
interface Middleware {
Response handle(Request req, Chain chain);
}
interface Chain { Response proceed(Request req); }
final class Pipeline {
private final List<Middleware> middlewares;
private final Function<Request, Response> endpoint;
Pipeline(List<Middleware> middlewares, Function<Request, Response> endpoint) {
this.middlewares = List.copyOf(middlewares); this.endpoint = endpoint;
}
Response execute(Request req) { return step(0).proceed(req); }
private Chain step(int i) {
return r -> i < middlewares.size() ? middlewares.get(i).handle(r, step(i + 1)) : endpoint.apply(r);
}
}
class TimingMiddleware implements Middleware {
public Response handle(Request req, Chain chain) {
long start = System.nanoTime();
try { return chain.proceed(req); } // code AFTER proceed runs on the way back out
finally { metrics.record(req.path(), System.nanoTime() - start); }
}
}
class AuthMiddleware implements Middleware {
public Response handle(Request req, Chain chain) {
if (!tokens.isValid(req.header("Authorization"))) return Response.status(401); // stop the chain
return chain.proceed(req);
}
}
This form handles something the handle-or-pass form doesn't: wrapping. Each middleware runs code both before and after the rest of the chain, which is exactly what timing, transactions, logging and exception translation need. It's the same design as javax.servlet.Filter (chain.doFilter(req, res)) and Spring's HandlerInterceptor/OncePerRequestFilter.
Order changes behaviour and cost:
Spring Security makes this explicit: its filters have a defined order, and custom filters are inserted relative to named ones (addFilterBefore(myFilter, UsernamePasswordAuthenticationFilter.class)).
Q: What happens if no handler in the chain processes the request at all? A: In the implementation above, the request falls off the end silently (no explicit final handler) — production chains typically add a terminal "default handler" at the end that either accepts (if reaching the end implies success) or explicitly rejects, so there's no ambiguous silent-fallthrough case.
Q: Can more than one handler in the chain process the same request? A: Yes, depending on design — the version above stops the chain entirely on rejection but continues past every handler that accepts, meaning all three checks run in sequence for a valid request (not just the first one). A different variant could have exactly one handler "claim" and fully handle a request, stopping the chain on acceptance too — the pattern's core shape (link handlers, pass along) accommodates either policy depending on what the problem needs.
Q: How is Chain of Responsibility different from just calling three validation methods in sequence in one function? A: Functionally similar for a fixed, known set of checks — the pattern's value shows up when the set of handlers needs to be configured, reordered, or extended without modifying a central method (matching the Open/Closed Principle), or when handlers need to be assembled differently in different contexts (e.g. a different chain for public vs authenticated endpoints).
Q: Does the order of handlers in the chain matter here, and why? A: Yes, meaningfully — AuthCheck runs before RateLimitCheck here specifically so an unauthenticated request gets a 401 rather than consuming rate-limit budget it may not even be entitled to; a different ordering (rate-limit before auth) would let unauthenticated traffic exhaust the rate limiter, a real-world reason chain ordering is a deliberate design decision, not an arbitrary one.
Q: What's the difference between the classic chain and a middleware pipeline? A: In the classic chain, exactly one handler processes the request, and the others just pass it on. In a middleware pipeline, every handler can act on the request and on the response (before and after calling the next one), and any handler can stop the chain early.
Q: How do you make the order of handlers explicit and safe?
A: Keep the chain as an ordered list built in one place (configuration or a builder), not scattered setNext calls. Document or enforce order constraints, for example authentication before authorization, and test that the assembled chain is in the expected order.
Q: How do servlet filters implement this pattern?
A: Each Filter.doFilter(request, response, chain) does its work, then calls chain.doFilter(...) to continue, or returns without calling it to stop processing. Code after that call runs as the response travels back out through the filters, in reverse order.