Chain of Responsibility Design Pattern in Java: Complete Guide

A support ticket comes in. Should it go to Level-1 support, Level-2 engineering, a senior architect, or the on-call incident team? The severity determines the right handler — but the code submitting the ticket shouldn’t need to know the routing rules. The Chain of Responsibility pattern lets you define a sequence of handlers, each deciding whether to handle a request or pass it to the next handler in the chain. The sender fires into the chain; the chain figures out who deals with it.

This pattern shows up in more places than you might expect: Java Servlet filters, Spring’s interceptor chain, Java’s logging framework, and every middleware pipeline in modern web frameworks all use the same structure. Understanding it from first principles makes those frameworks far less mysterious.

The Problem: Rigid Routing Logic

Without the pattern, you end up with a dispatcher method full of conditions:

// Without Chain of Responsibility — brittle, violates Open/Closed
void routeTicket(SupportTicket ticket) {
    if (ticket.getPriority() == LOW) {
        level1.handle(ticket);
    } else if (ticket.getPriority() == MEDIUM) {
        level2.handle(ticket);
    } else if (ticket.getPriority() == HIGH) {
        level3.handle(ticket);
    } else {
        criticalTeam.handle(ticket);
    }
    // Adding a new priority level or a new pre-processing step
    // means modifying this method — and every caller that uses it.
}

Adding a new handler (say, a “security review” step for any external-origin ticket) requires finding and modifying this dispatcher. Chain of Responsibility moves the routing logic into each handler itself and lets you compose chains without touching existing code.

Pattern Structure

Chain of Responsibility structure (via refactoring.guru)
Each handler holds a reference to the next handler. It either handles the request or passes it along. Diagram: refactoring.guru

Implementation: Support Ticket Escalation

The abstract base handler uses a Template Method to manage the chain: handleRequest() calls canHandle() to decide, then either calls handle() or passes to next. Subclasses only implement the two abstract methods.

package chain;
public abstract class SupportHandler {
    private SupportHandler next;
    // Fluent API: l1.setNext(l2).setNext(l3).setNext(critical)
    public SupportHandler setNext(SupportHandler next) {
        this.next = next;
        return next;
    }
    public final void handleRequest(SupportTicket ticket) {
        if (canHandle(ticket)) {
            handle(ticket);
        } else if (next != null) {
            System.out.println("  [" + getClass().getSimpleName() + "] passing up...");
            next.handleRequest(ticket);
        } else {
            System.out.println("  [UNHANDLED] No handler for: " + ticket);
        }
    }
    protected abstract boolean canHandle(SupportTicket ticket);
    protected abstract void handle(SupportTicket ticket);
}
package chain;
public class SupportTicket {
    public enum Priority { LOW, MEDIUM, HIGH, CRITICAL }
    private final String description;
    private final Priority priority;
    public SupportTicket(String description, Priority priority) {
        this.description = description;
        this.priority    = priority;
    }
    public Priority getPriority() { return priority; }
    @Override
    public String toString() { return "[" + priority + "] " + description; }
}

Each concrete handler checks only its own responsibility:

public class Level1Support extends SupportHandler {
    @Override
    protected boolean canHandle(SupportTicket t) {
        return t.getPriority() == SupportTicket.Priority.LOW;
    }
    @Override
    protected void handle(SupportTicket t) {
        System.out.println("  [Level-1] Resolved with FAQ: " + t);
    }
}
public class Level2Support extends SupportHandler {
    @Override
    protected boolean canHandle(SupportTicket t) {
        return t.getPriority() == SupportTicket.Priority.MEDIUM;
    }
    @Override
    protected void handle(SupportTicket t) {
        System.out.println("  [Level-2] Diagnosed and fixed: " + t);
    }
}
// Level3Support and CriticalIncidentTeam follow the same pattern

The chain is assembled in one place. Note the fluent setNext() — it returns the handler passed in, so you can chain the calls:

public class Main {
    public static void main(String[] args) {
        // Build: L1 -> L2 -> L3 -> Critical
        SupportHandler l1 = new Level1Support();
        l1.setNext(new Level2Support())
          .setNext(new Level3Support())
          .setNext(new CriticalIncidentTeam());
        SupportTicket[] tickets = {
            new SupportTicket("Can't find the login button",   SupportTicket.Priority.LOW),
            new SupportTicket("API returns 500 on /checkout",  SupportTicket.Priority.MEDIUM),
            new SupportTicket("Database replication lag > 30s",SupportTicket.Priority.HIGH),
            new SupportTicket("Complete payment outage",        SupportTicket.Priority.CRITICAL),
        };
        for (SupportTicket t : tickets) {
            System.out.println("Ticket: " + t);
            l1.handleRequest(t);
            System.out.println();
        }
    }
}

Console Output

=== Chain of Responsibility Demo ===

Ticket: [LOW] Can’t find the login button
  [Level-1] Resolved with FAQ: [LOW] Can’t find the login button

Ticket: [MEDIUM] API returns 500 on /checkout
  [Level1Support] passing up…
  [Level-2] Diagnosed and fixed: [MEDIUM] API returns 500 on /checkout

Ticket: [HIGH] Database replication lag > 30s
  [Level1Support] passing up…
  [Level2Support] passing up…
  [Level-3] Engineering deep-dive completed: [HIGH] Database replication lag > 30s

Ticket: [CRITICAL] Complete payment outage
  [Level1Support] passing up…
  [Level2Support] passing up…
  [Level3Support] passing up…
  [CRITICAL TEAM] All-hands war room opened: [CRITICAL] Complete payment outage

=== Demo complete ===

💡 Chain vs Command: Chain of Responsibility passes the request along until one handler claims it. Command wraps a request as an object and delivers it to exactly one known receiver. In Chain, the sender doesn’t know who will handle it. In Command, the invoker doesn’t know the details of how, but the command object knows its receiver. Use Chain when routing is dynamic; use Command when you want undoable, queued, or logged operations.

Chain of Responsibility in the JDK

Java Servlet Filters (javax.servlet.Filter) form a chain. Each filter either processes the request/response itself or calls chain.doFilter(request, response) to pass it to the next filter. Authentication, logging, compression, and CORS handling are all individual filters in a web application’s filter chain.

Java’s logging framework uses the same structure. A Logger publishes a log record; if the record’s level is not handled locally, the logger passes it to its parent logger. The parent may pass it further up to the root logger. Each logger in the hierarchy decides whether to handle or propagate.

✅ Best Practice — Who Assembles the Chain? The chain should be assembled by application wiring code (a factory, a Spring configuration, a main method) — not by the handlers themselves. If handlers know about each other, you’ve re-introduced the coupling the pattern was meant to eliminate. Keep each handler ignorant of its neighbours; let the wiring layer compose the chain from outside.

When to Use Chain of Responsibility

Reach for it when: more than one object might handle a request and the handler isn’t known at design time. You want to issue a request to one of several objects without specifying the receiver explicitly. The set of handlers or their order needs to change dynamically. You’re building a processing pipeline where each stage optionally transforms or short-circuits the request (middleware, filters, interceptors).

Avoid it when: there’s always exactly one handler — just call it directly. The chain is very long and performance of unhandled requests (which must traverse the whole chain) matters. Requests must be guaranteed to be handled — unhandled requests silently disappear unless you add a terminal default handler.

⚠️ Guarantee Termination: Always add a terminal catch-all handler at the end of the chain — one that handles everything (or at minimum logs that nothing handled the request). A chain that silently drops requests is a debugging nightmare. In our example, removing CriticalIncidentTeam would mean CRITICAL tickets vanish with no trace. The base handler’s else clause provides a fallback, but make it loud.

Runnable Code on GitHub

Full source at ankurm.com/git.app/asmhatre/design-patterns under 03-behavioral/chain-of-responsibility/.

javac 03-behavioral/chain-of-responsibility/*.java -d out/chain
java -cp out/chain chain.Main

See Also

Further Reading

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.