An order entering the system has to pass several checks: is the customer logged in, is the item in stock, does the payment exceed the limit. If you write all of that in one long method full of nested if statements, every new rule means editing that same method. Chain of Responsibility solves this by splitting each rule into its own handler and linking the handlers into a chain. This article walks through Chain of Responsibility from a plain Java implementation to the two best-known examples in the Java ecosystem: Servlet filters and Spring Security.

This article is part of the Java & Design Patterns series. If you want to see another behavioral pattern, besides Chain of Responsibility, that handles the problem of "several objects take part in processing", read Command pattern: command queues, undo and redo and Template Method: algorithm skeletons and extension points. All the code below was actually run on JDK 21 (OpenJDK 21.0.x) and Spring Boot 3.2.1 (with Spring Security 6.2.1) on a machine with 16 GB of RAM.

The problem: a hard-coded chain of checks in one function

Picture the validate(Order) method of an online store. At first it only checks the customer. Then the business adds a stock check, then a prepayment limit for large orders, then duplicate-order detection. The method keeps growing, the order of the steps is buried in code, and you cannot turn a step on or off through configuration without editing it. The caller is also forced to know every step.

These problems share one root: the sender of a request knows too much about the handler. Chain of Responsibility removes that coupling. According to refactoring.guru, Chain of Responsibility is a behavioral pattern that lets you pass requests along a chain of handlers, where each handler decides either to process the request or to pass it to the next handler. The GoF book (1994) states a similar goal for Chain of Responsibility: avoid coupling the sender of a request to its receiver.

The structure of Chain of Responsibility

Chain of Responsibility has four roles, and most real implementations use only three of them.

RoleResponsibilityIn the order example
HandlerDeclares the common interface, usually a single handling methodHandler.handle(Order)
Base handler (optional)Holds a reference to the next handler and provides default forwarding behaviorBaseHandler.pass()
Concrete handlerContains the real logic and decides whether to handle and whether to forwardAuthCheck, StockCheck, CreditLimit
ClientBuilds the chain and sends the request to the first handlermain

The key point is that the decision belongs to each handler. Every link has three choices: handle the request and stop, handle part of it and forward, or do nothing and forward it untouched. This "can stop at any moment" property is what sets Chain of Responsibility apart from Decorator, even though the structures of the two patterns look alike.

A plain Java implementation of Chain of Responsibility

The first example validates an order through three links. Each handler returns an Optional<String>: empty means nobody has rejected the order yet, and a value is the rejection reason. The base class BaseHandler holds the next pointer and the pass method so subclasses do not repeat the forwarding code.

    abstract static class BaseHandler implements Handler {
        private Handler next;
        BaseHandler then(BaseHandler n) { this.next = n; return n; }
        protected Optional<String> pass(Order o) {
            return next == null ? Optional.empty() : next.handle(o);
        }
    }

    static class AuthCheck extends BaseHandler {
        public Optional<String> handle(Order o) {
            System.out.println("  [Auth] checking customer " + o.customerId());
            if (o.customerId() == null || o.customerId().isBlank()) return Optional.of("401 missing customer");
            return pass(o);
        }
    }

The two classes StockCheck and CreditLimit follow the same mold as AuthCheck and differ only in their rejection condition (see the full file in the article's code folder).

The then method returns the handler that was just added, so you can write head.then(a).then(b) to build the chain fluently. Running java OrderChain.java (JDK 21 can run a source file directly) gives the following result.

Order of KH001
  [Auth] checking customer KH001
  [Stock] checking stock SP-01 x2
  [Credit] checking limit 12000000
  => 200 valid
Order of (empty)
  [Auth] checking customer 
  => 401 missing customer
Order of KH002
  [Auth] checking customer KH002
  [Stock] checking stock SP-02 x9
  => 409 exceeds stock (max 5)
Order of KH003
  [Auth] checking customer KH003
  [Stock] checking stock SP-03 x1
  [Credit] checking limit 80000000
  => 402 orders over 50,000,000 VND must be prepaid

Look at the second and third orders: when AuthCheck or StockCheck rejects, the links after it do not run, because no [Stock] or [Credit] line is printed. That is how a request is "handled" in the middle of the chain. To add a duplicate-order rule to this Chain of Responsibility, you write one new class and link it into the chain without touching the three old classes, which follows the Open/Closed spirit in SOLID principles in Java with practical examples.

Servlet filters: Chain of Responsibility in Java EE

The classic example of Chain of Responsibility in Java lives in the Servlet API. According to the Filter Javadoc, a filter performs filtering on the request to a resource, on the response from a resource, or both (this Javadoc belongs to Java EE 7 with the javax.servlet package). The doFilter method also receives a FilterChain object, and the documentation states that a filter must pick one of two options: either call chain.doFilter(request, response) to forward, or not call it to block further processing, for example an authentication filter rejecting a user who is not logged in. The Javadoc's list of examples includes authentication, logging and auditing, data compression, and encryption.

A note on the package name: since Servlet 5.0 (Jakarta EE 9), the package changed from javax.servlet to jakarta.servlet, while the contract of Filter and FilterChain stayed the same. The Spring Boot 3.2.1 code in this article uses jakarta.servlet; you can compare it with the Jakarta Servlet 6.0 Javadoc for Filter.

The container (Tomcat, Jetty, Undertow) builds the chain in declaration order, each filter calls chain.doFilter to move on, and when the filters run out the request reaches the servlet. To show that Chain of Responsibility is not mysterious here, the code below implements a small Chain with the same contract. Each filter is a lambda, and the pos pointer in Chain plays the role of the "next handler".

    record Request(String path, Map<String, String> headers) {}
    static class Response { int status = 200; String body = ""; }

    interface Filter { void doFilter(Request req, Response res, Chain chain); }

    static class Chain {
        private final List<Filter> filters; private final Runnable servlet; private int pos = 0;
        Chain(List<Filter> filters, Runnable servlet) { this.filters = filters; this.servlet = servlet; }
        void doFilter(Request req, Response res) {
            if (pos < filters.size()) filters.get(pos++).doFilter(req, res, this);
            else servlet.run();
        }
    }

Three filters are declared as lambdas in main: the logging filter calls chain.doFilter in the middle, the authentication filter returns 401 and returns without forwarding when the token is missing, and the authorization filter returns 403 for the /admin path if the role is not admin.

The result of running four different requests:

-> log in /orders
   auth: ok
   authz: ok
   servlet handles
<- log out /orders status=200
== result 200

-> log in /orders
   auth: rejected, NOT calling chain.doFilter
<- log out /orders status=401
== result 401

-> log in /admin/users
   auth: ok
   authz: forbid /admin
<- log out /admin/users status=403
== result 403

-> log in /admin/users
   auth: ok
   authz: ok
   servlet handles
<- log out /admin/users status=200
== result 200

This output shows three things about Chain of Responsibility. First, the logging filter wraps the rest of the chain: the -> log in line prints before chain.doFilter is called and the <- log out line prints after the call returns, so it sees even the status set by the filters behind it. Second, on the rejected request "servlet handles" does not appear: the authentication filter stopped the chain by not forwarding. Third, a filter that stops the chain must write the response itself, because nobody else will write it.

Spring Security: one SecurityFilterChain made of many filters

Spring Security builds all servlet security on top of the Servlet filter Chain of Responsibility. According to the Spring Security Servlet architecture documentation, DelegatingFilterProxy is a Servlet filter that bridges the container and the ApplicationContext; it delegates to FilterChainProxy, and FilterChainProxy picks a SecurityFilterChain that matches the request. Only the first matching chain runs. So there are two layers: the outer layer selects a chain by RequestMatcher, and the inner layer is the real Chain of Responsibility among the security filters.

The documentation also stresses a consequence of Chain of Responsibility: because a filter only affects the filters after it and the servlet, the order of invocation is extremely important, and the authentication filter must run before the authorization filter. Below is the real configuration of a small API with two chains. The first chain applies only to /api/** and adds two custom filters; the second chain is open for everything else.

    @Bean
    SecurityFilterChain api(HttpSecurity http, @Value("${chain.wrong:false}") boolean wrong) throws Exception {
        // wrong=true: deliberately place ApiKeyFilter AFTER AuthorizationFilter to show why order matters
        if (wrong) http.addFilterAfter(new ApiKeyFilter(), AuthorizationFilter.class);
        else http.addFilterBefore(new ApiKeyFilter(), AnonymousAuthenticationFilter.class);
        http.securityMatcher("/api/**")
            .csrf(AbstractHttpConfigurer::disable)
            .addFilterBefore(new RequestIdFilter(), SecurityContextHolderFilter.class)
            .authorizeHttpRequests(a -> a
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated());
        return http.build();
    }

The custom authentication filter reads the X-Api-Key header. A valid key puts an Authentication object into SecurityContextHolder and then calls chain.doFilter to continue. An invalid key stops the chain and writes a 401 response itself. With no key at all, the request is simply forwarded so later filters can decide. These are two different decisions made by the same link.

    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String key = req.getHeader("X-Api-Key");
        if (key != null) {
            switch (key) {
                case "demo-user-key" -> SecurityContextHolder.getContext().setAuthentication(
                    new UsernamePasswordAuthenticationToken("ketoan01", null, List.of(new SimpleGrantedAuthority("ROLE_USER"))));
                case "demo-admin-key" -> SecurityContextHolder.getContext().setAuthentication(
                    new UsernamePasswordAuthenticationToken("quantri01", null, List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))));
                default -> {
                    // invalid key: stop the chain and write the response ourselves (no chain.doFilter)
                    res.setStatus(401);
                    res.setContentType("text/plain;charset=UTF-8");
                    res.getWriter().write("Invalid API key");
                    return;
                }
            }
        }
        chain.doFilter(req, res);
    }
}

Spring's parent class OncePerRequestFilter guarantees that the filter runs only once per request even with an internal forward. The test uses MockMvc and FilterChainProxy to print the real filter list of each chain, then sends six requests. The filter list of the /api/** chain (13 filters) is below, with the two custom filters at the positions we specified. The <- custom annotations were added by the author; the full test output (both chains, with filter counts) is saved in code/s6-16/out/spring-filters.txt:

DisableEncodeUrlFilter
WebAsyncManagerIntegrationFilter
RequestIdFilter                      <- custom, placed before SecurityContextHolderFilter
SecurityContextHolderFilter
HeaderWriterFilter
CorsFilter
LogoutFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
ApiKeyFilter                         <- custom, placed before AnonymousAuthenticationFilter
AnonymousAuthenticationFilter
ExceptionTranslationFilter
AuthorizationFilter

The second chain has 11 filters and neither custom filter. The result of the six requests with the correct order:

GET /api/orders        key=null           -> 403  X-Request-Id=yes  body=
GET /api/orders        key=sai-key        -> 401  X-Request-Id=yes  body=Invalid API key
GET /api/orders        key=demo-user-key  -> 200  X-Request-Id=yes  body=orders for ketoan01
GET /api/admin/report  key=demo-user-key  -> 403  X-Request-Id=yes  body=
GET /api/admin/report  key=demo-admin-key -> 200  X-Request-Id=yes  body=report for quantri01
GET /public/ping       key=null           -> 200  X-Request-Id=no  body=pong

The request to /api/orders without a key gets 403: our filter forwards, AnonymousAuthenticationFilter assigns an anonymous user, and AuthorizationFilter rejects it. The status is 403 rather than 401 because the default configuration here declares no form login or HTTP Basic, so no entry point asks for credentials. The request to /public/ping goes through the second chain and therefore has no X-Request-Id, exactly as predicted.

Order matters: try placing a filter in the wrong spot

To verify the documentation's warning about Chain of Responsibility, we deliberately place ApiKeyFilter after AuthorizationFilter using the environment variable CHAIN_WRONG=true. Now authorization runs before anyone has authenticated, so every request to /api/** is rejected even with a valid key.

GET /api/orders        key=null           -> 403  X-Request-Id=yes  body=
GET /api/orders        key=sai-key        -> 403  X-Request-Id=yes  body=
GET /api/orders        key=demo-user-key  -> 403  X-Request-Id=yes  body=
GET /api/admin/report  key=demo-user-key  -> 403  X-Request-Id=yes  body=
GET /api/admin/report  key=demo-admin-key -> 403  X-Request-Id=yes  body=
GET /public/ping       key=null           -> 200  X-Request-Id=no  body=pong

The request with demo-admin-key also gets 403. The authentication filter sits at the end of the chain, after the decision has already been made, so it never gets the chance to set the Authentication. The guide in the Spring Security documentation suggests a rule for placing custom filters: put authentication filters after LogoutFilter and authorization filters after AnonymousAuthenticationFilter. With HttpSecurity, you use addFilterBefore, addFilterAfter or addFilterAt to specify the position.

One more detail related to Chain of Responsibility: ExceptionTranslationFilter calls chain.doFilter inside a try block and catches both AccessDeniedException and AuthenticationException from the filters after it, then converts them into HTTP responses. In other words, one link can wrap the entire rest of the chain to handle errors, just like a catch block wrapping a function call. The consequence: a custom filter that throws an exception at a position before ExceptionTranslationFilter will not be translated into a 403 and will instead escape.

Variants of Chain of Responsibility in practice

There are two basic forms. The first is "one handler": the request travels down the chain until some handler accepts it, and then stops. The second is "the whole chain handles it", as with Servlet filters, where each link does part of the work and forwards. Both are Chain of Responsibility and differ only in the stopping condition. Some other places in the Java ecosystem that use Chain of Responsibility:

  • Spring MVC HandlerInterceptor: interceptors are called in turn through preHandle, and if one preHandle returns false the chain stops.
  • The try/catch block: catch clauses are checked in order from top to bottom, and the first clause that matches the exception type takes it.
  • ClassLoader delegation: each class loader asks its parent first and loads the class itself only if the parent does not have it. This is a variant that goes up first and then down.

Compared with nearby patterns, refactoring.guru notes that Chain of Responsibility differs from Decorator in that a handler can execute arbitrary operations and stop the request at any point, while Decorator extends behavior without cutting the flow. Compared with Observer pattern: from listeners to event buses and the Flow API, the difference is that Observer notifies every subscriber, while Chain of Responsibility hands over control in order and can stop.

When not to use Chain of Responsibility

Chain of Responsibility is not free. Its biggest drawback, according to refactoring.guru's own description, is that a request can reach the end of the chain without anyone handling it. In the order example, if there is no final "accept" link, you cannot tell "valid" apart from "missed". So always define the default behavior when the chain ends: throw an error, return an explicit default value, or log a warning.

Some cases where you should not use Chain of Responsibility:

  • There are only two or three fixed steps that never change: a method with a few clear if statements is easier to read than a set of classes.
  • You need to know for sure who handles the request: a dynamic chain makes debugging harder, because the execution flow is scattered across many classes. If the rule for choosing the handler is simple (by type), a Map from type to handler or Strategy pattern with lambdas and functional interfaces is more direct.

Common mistakes when implementing Chain of Responsibility

The mistakes below show up again and again when writing custom filters or handlers.

  • Forgetting to call chain.doFilter: the request hangs or returns a blank page, because the filter neither forwards nor writes a response.
  • Calling chain.doFilter twice: the servlet runs twice. Call it only once on each execution branch.
  • Stopping the chain without writing a response: the client receives a 200 with an empty body. When you block, set the status and the body.
  • Wrong order: as in the experiment above, authorization before authentication makes every request get rejected.
  • Shared state between requests: a filter is a singleton shared across threads, so do not store per-request data in fields of the class. Use request attributes or a ThreadLocal that is cleaned up.

Checklist for Chain of Responsibility

  • Each link in a Chain of Responsibility does exactly one job and decides on its own whether to forward.
  • Define clearly what happens when a request reaches the end of the chain without being handled.
  • Order is part of the contract: document it and write tests for it, like the test above that prints the filter list.
  • Links should be objects that hold no per-request state, so they can be shared safely across threads.
  • A link that stops the chain must write a complete result.

To rerun all the examples, use runall.sh in the article's code folder: the two Java files run directly with java File.java, and the Spring project runs with mvn test on JDK 21.

Next article

Chain of Responsibility sends a request through several handlers. The next article looks at the opposite situation: one object that changes its behavior according to its internal state. Read State pattern and an order state machine.