Skip to content

How to Implement the Chain of Responsibility Pattern in Java

Build an order desk in Java 21 with the chain of responsibility pattern: validators reject, approvers escalate, and a trail shows who decided. Gradle, tested.

how-to-tutorials design-patterns java-tutorials
Gopi Gorantala
Reading Progress

On This Page

Reject, escalate, approve: the finished order desk

Seven orders go into one object. Each comes back with a verdict, and the verdict says which handler made the call and every handler the order visited on the way:

A-1 -> ACCEPTED by clerk (approved by clerk) via KnownSku > InStock > MaxQuantity > clerk
A-2 -> ACCEPTED by manager (approved by manager) via KnownSku > InStock > MaxQuantity > clerk > manager
A-3 -> ACCEPTED by director (approved by director) via KnownSku > InStock > MaxQuantity > clerk > manager > director
A-4 -> REJECTED by InStock (only 8 of MON-27 on hand, wanted 9) via KnownSku > InStock
A-5 -> REJECTED by KnownSku (unknown SKU XX-000) via KnownSku
A-6 -> REJECTED by MaxQuantity (quantity 80 is over the per-order limit of 50) via KnownSku > InStock > MaxQuantity
A-7 -> REJECTED by director (end of chain reached without a decision) via KnownSku > InStock > MaxQuantity > clerk > manager > director

That's the chain of responsibility pattern in Java doing its job. The caller holds the first link and nothing else. It doesn't know there are three validators and three approvers, it doesn't know the order they run in, and it doesn't care. Order A-2 walked past the clerk because 1,800.00 is over the clerk's 500.00 limit, and the manager took it. Order A-4 never reached an approver because the stock check turned it away at the second link.

You'll build this on an inventory order desk: validation handlers that reject or pass, approval handlers that accept or escalate, and a small Verdict record that remembers the path. It's for a Java developer who has met if / else if ladders that keep growing and wants to see the pattern that replaces them, with code that runs on your machine. If you've read the strategy or command tutorials, this one is the third cousin: strategy picks one behaviour, command packages a request, and a chain lets several objects take a turn at the same request.

One design choice is worth flagging before you start. The textbook version has each handler call next.handle(...) itself, and a handler that forgets to makes orders stop silently. The version below moves the walking into the base class so no subclass can forget.

Requirements, with a check for each

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 (Ubuntu build) java -version
Gradle (optional) 8.14.3 gradle -v
IntelliJ IDEA or Eclipse (optional) any current release; not run for this article Help → About

You need JDK 21 or newer. The code uses a switch with type patterns over a sealed interface, which became final in Java 21 (JEP 441). On --release 17 the compiler stops at that switch, and the message is in the troubleshooting section.

No database, no container, no framework. There's nothing to pull and nothing listening on a port, so I didn't use Podman or Docker for this one. Dependencies on JUnit and friends are also absent on purpose: the tests are plain checks you can run anywhere. Maven Central wasn't reachable from my build machine, and I'd rather give you tests that run than tests that need a download. When you're ready to move them to JUnit 5, the forward link is at the end.

You should already know classes, interfaces and records. If sealed interfaces are new, the sealed classes tutorial covers them in ten minutes, and the records tutorial covers record.

Start the inventory project from an empty folder

I used Gradle this time. Make a folder and two build files.

mkdir order-desk && cd order-desk
mkdir -p src/main/java/com/example/inventory

settings.gradle.kts

rootProject.name = "order-desk"

build.gradle.kts

plugins {
    java
    application
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

application {
    mainClass = "com.example.inventory.Demo"
}

// ./gradlew checks  (or: gradle checks)
tasks.register<JavaExec>("checks") {
    classpath = sourceSets["main"].runtimeClasspath
    mainClass = "com.example.inventory.Checks"
}

The toolchain block makes Gradle insist on a 21 JDK. If you have a different JDK as your default, Gradle finds or fetches a 21 one instead of failing on a confusing syntax error later. The checks task is a second entry point so gradle run shows the demo and gradle checks runs the verification.

The finished tree:

order-desk/
├── settings.gradle.kts
├── build.gradle.kts
└── src/main/java/com/example/inventory/
    ├── Order.java
    ├── Outcome.java
    ├── Verdict.java
    ├── Handler.java
    ├── Stock.java
    ├── KnownSku.java
    ├── InStock.java
    ├── MaxQuantity.java
    ├── Approver.java
    ├── Pipelines.java
    ├── Demo.java
    ├── Checks.java
    └── Traps.java

In IntelliJ IDEA

Documented, not executed by me. Use File → New → Project, pick Java, set Build system to Gradle and Gradle DSL to Kotlin, and choose a JDK 21 in the JDK dropdown. Then paste the two build files over the generated ones and add the sources under src/main/java. Run Demo with the green gutter arrow next to main.

In Eclipse

Also from the docs, not run. File → New → Project → Gradle → Gradle Project (Buildship ships in the Eclipse IDE for Java Developers package), then set the Java home to a 21 JDK in the Gradle import options. Right-click Demo.java → Run As → Java Application.

From the command line with plain javac

No build tool needed. This is what I used for most of the runs while writing:

mkdir -p out
javac -Xlint:all -d out src/main/java/com/example/inventory/*.java
java -cp out com.example.inventory.Demo

Eight steps. Each ends with a checkpoint, and most of the checkpoints are a compile.

Step 1: Model the order

Goal: a value the whole chain passes around, impossible to build in a nonsense state.

src/main/java/com/example/inventory/Order.java

package com.example.inventory;

public record Order(String id, String sku, int quantity, long totalCents) {
    public Order {
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity must be positive: " + quantity);
        }
        if (totalCents < 0) {
            throw new IllegalArgumentException("total must not be negative: " + totalCents);
        }
    }
}

Money is in cents as a long because I don't want double anywhere near it; the BigDecimal tutorial covers the other option. The compact constructor throws for a zero quantity, so handlers never have to check.

Checkpoint: javac compiles the file with no output.

Step 2: Give handlers three ways to answer

Goal: say what a handler can do with an order, in the type system.

src/main/java/com/example/inventory/Outcome.java

package com.example.inventory;

public sealed interface Outcome {

    record Pass() implements Outcome { }

    record Accept(String reason) implements Outcome { }

    record Reject(String reason) implements Outcome { }

    static Outcome pass() {
        return new Pass();
    }

    static Outcome accept(String reason) {
        return new Accept(reason);
    }

    static Outcome reject(String reason) {
        return new Reject(reason);
    }
}

A handler can accept the order, reject it, or pass it along. That's the whole contract. Making it a sealed interface matters later: the base class switches over it, and the compiler will refuse to build if a fourth outcome appears and the switch doesn't handle it.

Checkpoint: compiles.

Step 3: Record the verdict and the path

Goal: return more than a boolean.

src/main/java/com/example/inventory/Verdict.java

package com.example.inventory;

import java.util.List;

public record Verdict(boolean accepted, String decidedBy, String reason, List<String> trail) {
    public Verdict {
        trail = List.copyOf(trail);
    }

    @Override
    public String toString() {
        return (accepted ? "ACCEPTED" : "REJECTED") + " by " + decidedBy
                + " (" + reason + ") via " + String.join(" > ", trail);
    }
}

List.copyOf in the compact constructor snapshots the trail. The handlers keep adding names to a working list while they walk; once the verdict exists it can't change under you. The immutability rules are in the immutable class tutorial.

Checkpoint: compiles. Verdict stands alone, so you can build it before anything else.

Step 4: Write the base handler that owns the walk

Goal: one class that links handlers and walks them, so subclasses only make decisions.

src/main/java/com/example/inventory/Handler.java

package com.example.inventory;

import java.util.ArrayList;
import java.util.List;

public abstract class Handler {

    private Handler next;

    /** Links the next handler and returns it, so calls can be chained left to right. */
    public Handler then(Handler next) {
        this.next = next;
        return next;
    }

    public final Verdict handle(Order order) {
        return handle(order, new ArrayList<>());
    }

    private Verdict handle(Order order, List<String> trail) {
        trail.add(name());
        return switch (inspect(order)) {
            case Outcome.Accept a -> new Verdict(true, name(), a.reason(), trail);
            case Outcome.Reject r -> new Verdict(false, name(), r.reason(), trail);
            case Outcome.Pass p -> next == null
                    ? new Verdict(false, name(), "end of chain reached without a decision", trail)
                    : next.handle(order, trail);
        };
    }

    /** Decide, or pass the order along. */
    protected abstract Outcome inspect(Order order);

    protected String name() {
        return getClass().getSimpleName();
    }
}

Read handle slowly, because it's the pattern. It adds its own name to the trail, asks the subclass to inspect the order, and then does one of three things. Accept and reject build a verdict and return, and the walk ends. Pass hands the same order and the same trail to next. If there's no next, it returns a rejection that says the chain ran out. Subclasses implement only inspect.

then returns the handler you passed in, not this. That's what lets you write a.then(b).then(c) and get a chain a → b → c. If it returned this, every call would overwrite the same handler's successor and you'd get a single link, not a chain. Trap 2 below shows that failure shape.

handle(Order) is final so nobody overrides the walk. The private overload takes the trail, which is why the public method hides it.

Checkpoint: javac -Xlint:all on the files so far (Order, Outcome, Verdict, Handler) prints nothing.

Step 5: Write the validators

Goal: three handlers that reject or pass. First, something to look up stock in.

src/main/java/com/example/inventory/Stock.java

package com.example.inventory;

import java.util.HashMap;
import java.util.Map;

public final class Stock {

    private final Map<String, Integer> onHand = new HashMap<>();

    public Stock with(String sku, int quantity) {
        onHand.put(sku, quantity);
        return this;
    }

    public boolean knows(String sku) {
        return onHand.containsKey(sku);
    }

    public int available(String sku) {
        return onHand.getOrDefault(sku, 0);
    }
}

src/main/java/com/example/inventory/KnownSku.java

package com.example.inventory;

public final class KnownSku extends Handler {

    private final Stock stock;

    public KnownSku(Stock stock) {
        this.stock = stock;
    }

    @Override
    protected Outcome inspect(Order order) {
        return stock.knows(order.sku())
                ? Outcome.pass()
                : Outcome.reject("unknown SKU " + order.sku());
    }
}

src/main/java/com/example/inventory/InStock.java

package com.example.inventory;

public final class InStock extends Handler {

    private final Stock stock;

    public InStock(Stock stock) {
        this.stock = stock;
    }

    @Override
    protected Outcome inspect(Order order) {
        int available = stock.available(order.sku());
        return available >= order.quantity()
                ? Outcome.pass()
                : Outcome.reject("only " + available + " of " + order.sku() + " on hand, wanted " + order.quantity());
    }
}

src/main/java/com/example/inventory/MaxQuantity.java

package com.example.inventory;

public final class MaxQuantity extends Handler {

    private final int limit;

    public MaxQuantity(int limit) {
        this.limit = limit;
    }

    @Override
    protected Outcome inspect(Order order) {
        return order.quantity() <= limit
                ? Outcome.pass()
                : Outcome.reject("quantity " + order.quantity() + " is over the per-order limit of " + limit);
    }
}

Each class is small on purpose. A validator doesn't know what comes before or after it, which means you can add, remove or reorder them without touching the others. That's the point of the exercise.

Checkpoint: all three compile.

Step 6: Write the approver that escalates

Goal: a handler that accepts orders within its limit and passes the rest up.

src/main/java/com/example/inventory/Approver.java

package com.example.inventory;

public final class Approver extends Handler {

    private final String title;
    private final long limitCents;

    public Approver(String title, long limitCents) {
        this.title = title;
        this.limitCents = limitCents;
    }

    @Override
    protected Outcome inspect(Order order) {
        return order.totalCents() <= limitCents
                ? Outcome.accept("approved by " + title)
                : Outcome.pass();
    }

    @Override
    protected String name() {
        return title;
    }
}

One class, three instances: a clerk, a manager, a director. Each is the same code with a different limit. It overrides name() so the trail reads clerk > manager instead of Approver > Approver. Without it, a trail with three approvers is unreadable.

Checkpoint: compiles.

Step 7: Assemble the desk in one place

Goal: keep the order of the chain in exactly one file.

src/main/java/com/example/inventory/Pipelines.java

package com.example.inventory;

public final class Pipelines {

    private Pipelines() { }

    /** Cheap checks first, then approvers from lowest limit to highest. Returns the head. */
    public static Handler orderDesk(Stock stock) {
        Handler head = new KnownSku(stock);
        head.then(new InStock(stock))
            .then(new MaxQuantity(50))
            .then(new Approver("clerk", 50_000))
            .then(new Approver("manager", 500_000))
            .then(new Approver("director", 5_000_000));
        return head;
    }
}

Look at the call shape. head is stored, and every then after the first goes through the returned handler. Cheap checks come first, so a typo'd SKU is rejected before anyone is asked to approve it. Approvers are sorted from the lowest limit to the highest, so the lowest-authority person who can approve it does.

Checkpoint: javac -Xlint:all prints nothing.

Step 8: Add the demo

Goal: a main method that feeds orders in and prints the verdicts.

src/main/java/com/example/inventory/Demo.java

package com.example.inventory;

import java.util.List;

public class Demo {

    public static void main(String[] args) {
        Stock stock = new Stock().with("KB-100", 40).with("MON-27", 8).with("DOCK-9", 500);
        Handler desk = Pipelines.orderDesk(stock);

        List<Order> orders = List.of(
                new Order("A-1", "KB-100", 2, 12_000),
                new Order("A-2", "MON-27", 4, 180_000),
                new Order("A-3", "MON-27", 8, 2_400_000),
                new Order("A-4", "MON-27", 9, 270_000),
                new Order("A-5", "XX-000", 1, 1_000),
                new Order("A-6", "DOCK-9", 80, 900_000),
                new Order("A-7", "DOCK-9", 40, 9_000_000));

        for (Order order : orders) {
            System.out.println(order.id() + " -> " + desk.handle(order));
        }
    }
}

Checkpoint: compiling everything with -Xlint:all produced no warnings for me.

Feed it seven orders

With Gradle:

gradle run

or with plain Java:

java -cp out com.example.inventory.Demo

You should get the output you saw at the top of the page. Read it once against the code:

A-1 -> ACCEPTED by clerk (approved by clerk) via KnownSku > InStock > MaxQuantity > clerk
A-2 -> ACCEPTED by manager (approved by manager) via KnownSku > InStock > MaxQuantity > clerk > manager
A-3 -> ACCEPTED by director (approved by director) via KnownSku > InStock > MaxQuantity > clerk > manager > director
A-4 -> REJECTED by InStock (only 8 of MON-27 on hand, wanted 9) via KnownSku > InStock
A-5 -> REJECTED by KnownSku (unknown SKU XX-000) via KnownSku
A-6 -> REJECTED by MaxQuantity (quantity 80 is over the per-order limit of 50) via KnownSku > InStock > MaxQuantity
A-7 -> REJECTED by director (end of chain reached without a decision) via KnownSku > InStock > MaxQuantity > clerk > manager > director

A-1 is 120.00, under the clerk's 500.00. A-2 is 1,800.00, which the clerk can't approve, so the manager does. A-3 is 24,000.00, so it goes all the way to the director. A-4 asks for nine of something with eight on hand, and InStock stops it. A-5 has a SKU nobody's heard of and dies at the first link with a one-element trail. A-6 wants 80 units and trips the limit of 50. A-7 is 90,000.00, over every approver's limit, and falls off the end of the chain.

That last one's the interesting verdict. It says "REJECTED by director" because the director was the last handler to look at it, but the reason says the chain ran out, which is a different thing from a person declining. I kept that distinction visible on purpose; hiding it is how you end up with orders nobody actually reviewed.

Pin the walk down with 15 checks

These are plain Java, no framework. A tiny check method counts passes and fails, and the process exits with status 1 if anything fails, so it works in a CI step.

src/main/java/com/example/inventory/Checks.java

package com.example.inventory;

public class Checks {

    private static int passed;
    private static int failed;

    public static void main(String[] args) {
        Stock stock = new Stock().with("KB-100", 40).with("MON-27", 8).with("DOCK-9", 500);
        Handler desk = Pipelines.orderDesk(stock);

        Verdict v = desk.handle(new Order("t1", "KB-100", 2, 12_000));
        check("small order accepted by clerk", v.accepted() && v.decidedBy().equals("clerk"));
        check("clerk trail is four handlers long", v.trail().size() == 4);

        v = desk.handle(new Order("t2", "MON-27", 4, 180_000));
        check("mid order escalates to manager", v.accepted() && v.decidedBy().equals("manager"));
        check("manager trail passes clerk first", v.trail().contains("clerk") && v.trail().indexOf("clerk") < v.trail().indexOf("manager"));

        v = desk.handle(new Order("t3", "MON-27", 8, 2_400_000));
        check("big order escalates to director", v.accepted() && v.decidedBy().equals("director"));

        v = desk.handle(new Order("t4", "MON-27", 9, 270_000));
        check("out of stock rejected by InStock", !v.accepted() && v.decidedBy().equals("InStock"));
        check("rejection stops the walk", v.trail().size() == 2);

        v = desk.handle(new Order("t5", "XX-000", 1, 1_000));
        check("unknown sku rejected by first handler", !v.accepted() && v.trail().equals(java.util.List.of("KnownSku")));

        v = desk.handle(new Order("t6", "DOCK-9", 80, 900_000));
        check("over quantity limit rejected", !v.accepted() && v.decidedBy().equals("MaxQuantity"));

        v = desk.handle(new Order("t7", "DOCK-9", 40, 9_000_000));
        check("over every limit falls off the end", !v.accepted() && v.reason().startsWith("end of chain"));
        check("end-of-chain decided by last handler", v.decidedBy().equals("director"));

        // the same order, handled twice, gives equal verdicts and a fresh trail each time
        Order o = new Order("t8", "KB-100", 1, 100);
        check("handling is repeatable", desk.handle(o).equals(desk.handle(o)));

        // a head with no successor and no decision
        Verdict lone = new KnownSku(stock).handle(new Order("t9", "KB-100", 1, 100));
        check("lone pass-through hits end of chain", !lone.accepted() && lone.reason().startsWith("end of chain"));

        // verdict trail is a copy
        boolean threw = false;
        try { v.trail().add("x"); } catch (UnsupportedOperationException e) { threw = true; }
        check("verdict trail is immutable", threw);

        // order validation
        threw = false;
        try { new Order("bad", "KB-100", 0, 1); } catch (IllegalArgumentException e) { threw = true; }
        check("zero quantity rejected at construction", threw);

        System.out.println(passed + " passed, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }

    private static void check(String name, boolean ok) {
        if (ok) { passed++; System.out.println("PASS " + name); }
        else { failed++; System.out.println("FAIL " + name); }
    }
}

Run it:

gradle checks

or

java -cp out com.example.inventory.Checks
PASS small order accepted by clerk
PASS clerk trail is four handlers long
PASS mid order escalates to manager
PASS manager trail passes clerk first
PASS big order escalates to director
PASS out of stock rejected by InStock
PASS rejection stops the walk
PASS unknown sku rejected by first handler
PASS over quantity limit rejected
PASS over every limit falls off the end
PASS end-of-chain decided by last handler
PASS handling is repeatable
PASS lone pass-through hits end of chain
PASS verdict trail is immutable
PASS zero quantity rejected at construction
15 passed, 0 failed

Passing checks only mean something if they can fail, so I mutated the base class: I replaced the next.handle(order, trail) call with a hard-coded rejection, so a pass would stop the walk. Ten checks went red and five stayed green:

FAIL small order accepted by clerk
FAIL clerk trail is four handlers long
FAIL mid order escalates to manager
FAIL manager trail passes clerk first
FAIL big order escalates to director
FAIL out of stock rejected by InStock
FAIL rejection stops the walk
FAIL over quantity limit rejected
FAIL over every limit falls off the end
FAIL end-of-chain decided by last handler
5 passed, 10 failed

Moving these to JUnit 5 and AssertJ is mostly mechanical. The forward link to that tutorial is in the next sections.

Why the base class owns the walk

There are two common ways to write this pattern. In the textbook version, every handler calls next.handle(order) itself. In this version the base class does it, and the subclass returns a decision. The second shape has one big advantage: a handler can't forget to call the next one, which is the bug I made on my first try. It has one small cost: a handler can't do work after the rest of the chain finishes, the way a servlet filter does on its way back out. If you need that, you want the textbook shape, and you accept the forgetting risk.

The sealed Outcome is doing quiet work. Because Handler.handle switches over it without a default branch, adding a fourth outcome later, say "hold for review", is a compile error in exactly one place until you decide what the walk does with it.

The Verdict trail exists because chains are famously hard to debug. When something gets rejected three handlers deep, "rejected" is useless; "rejected by InStock after KnownSku" is a bug report you can act on.

You've seen the same idea in real frameworks. jakarta.servlet.FilterChain is a chain where each filter decides whether to call the rest. Spring Security's filter chain is the same shape with more links. If you want the related request-wrapping pattern, the decorator tutorial is next door: a decorator always forwards to the thing it wraps, a chain link may stop the request. For turning requests into objects with undo, see command; for notifying many listeners instead of stopping at the first taker, see observer.

Three ways to break a chain on purpose

I forced each of these. The first two are quiet, which is what makes them dangerous.

src/main/java/com/example/inventory/Traps.java

package com.example.inventory;

public class Traps {

    public static void main(String[] args) {
        Stock stock = new Stock().with("MON-27", 8);
        Order outOfStock = new Order("T-1", "MON-27", 9, 270_000);

        System.out.println("-- Trap 1: approvers placed before the stock check");
        Handler wrong = new Approver("clerk", 500_000);
        wrong.then(new KnownSku(stock)).then(new InStock(stock));
        System.out.println(wrong.handle(outOfStock));

        System.out.println("-- Trap 2: calling then() on the head twice");
        Handler head = new KnownSku(stock);
        head.then(new InStock(stock));
        head.then(new MaxQuantity(5));
        System.out.println(head.handle(outOfStock));

        System.out.println("-- Trap 3: a cycle in the chain");
        Handler a = new KnownSku(stock);
        Handler b = new MaxQuantity(100);
        a.then(b).then(a);
        try {
            a.handle(new Order("T-3", "MON-27", 1, 100));
        } catch (StackOverflowError e) {
            System.out.println("StackOverflowError after the walk looped forever");
        }
    }
}
-- Trap 1: approvers placed before the stock check
ACCEPTED by clerk (approved by clerk) via clerk
-- Trap 2: calling then() on the head twice
REJECTED by MaxQuantity (quantity 9 is over the per-order limit of 5) via KnownSku > MaxQuantity
-- Trap 3: a cycle in the chain
StackOverflowError after the walk looped forever

Order matters, and nothing warns you. Trap 1 put the clerk approver at the head of the chain. The order wants nine units with eight on hand, but the clerk looked at the total, saw it was under the limit and approved it. The visible symptom is an accepted order that should have been rejected, and the trail, a single entry, is the clue. The fix is to run cheap rejections first and approvals last. Keep the chain order in one place, like Pipelines.

Calling then on the head twice drops a link. Trap 2 wrote head.then(a) and then head.then(b). The second call overwrites the successor, so InStock vanished and the trail shows KnownSku > MaxQuantity. The fix is to chain off the handler then returns, as Pipelines does.

A cycle blows the stack. Trap 3 linked the last handler back to the first. Every handler passed, so the walk never ended and Java threw StackOverflowError. The fix is to build chains in one function and never hand a handler out to be re-linked. If your chain can be edited at runtime, check for cycles when you link.

Three more things you'll hit while following along:

What you see What it means Fix
error: Lazy is not abstract and does not override abstract method inspect(Order) in Handler A subclass skipped the one method that makes it a handler Implement inspect(Order)
error: patterns in switch statements are not supported in -source 17 You compiled with a JDK or --release below 21 Use JDK 21, or check languageVersion in build.gradle.kts
The demo prints end of chain reached without a decision for orders you expected to approve No handler claimed them; the last approver's limit is too low or the chain is cut short Print the trail and see which handler was last

The first two error messages are copied from my compiler output; I made the Lazy subclass on purpose and compiled with --release 17.

What changes in a real service

  • Put the chain in configuration, not in a method. Here the order is in Pipelines. In a service you'd build it once at startup, from a list, and make it read-only. If product needs a rule changed, you change the list.
  • Decide what "end of chain" means. I made it a rejection. Another reasonable choice is a thrown exception, or a fallback handler that queues the order for a human. Whichever you pick, test it.
  • Don't use the pattern for two handlers. An if is shorter. I'd reach for a chain at about four, when the branches have their own dependencies and you've started adding "just one more" check.
  • Stateless handlers are thread-safe. Mine hold only final fields, so one desk can serve many threads. If a handler keeps counters or caches, protect them; the atomic classes tutorial is the starting point.
  • Version note. The switch over Outcome needs Java 21. On Java 17 you'd write instanceof checks, or use a visitor, and lose the exhaustiveness check.
  • Next reads. A chain that moves between named states is a state machine; writing checks like these as JUnit tests is unit tests with JUnit 5; both are on the way.

The chain in six lines

  • Give handlers one job: return accept, reject or pass.
  • Put the walking in the base class, so no subclass can forget to call the next link.
  • Make then return the handler you passed in, and always chain off the returned value.
  • Order the chain cheap rejections first, approvals last, and keep that order in one file.
  • Return a verdict that records the path, and keep "ran out of handlers" distinct from "someone said no".
  • Check the whole thing, then break it once on purpose to see the checks go red.

Chain of responsibility questions people ask

What is the chain of responsibility pattern in Java?
It's a behavioural pattern where a request goes to the first object in a linked list of handlers. Each handler either deals with it or passes it on. The sender only knows the first handler.

What's the difference between chain of responsibility and decorator?
A decorator always forwards to the object it wraps and adds behaviour around it. A chain link decides, and it may stop the request there. If every link always forwards, you've written a decorator stack.

Should each handler call the next one itself?
The textbook version does, and it lets a handler act before and after the rest of the chain. The version here moves the call into the base class so it can't be forgotten, at the cost of that after-work. Pick based on whether you need it.

Is the chain of responsibility pattern used in the JDK?
Yes, in spirit: jakarta.servlet.FilterChain in Jakarta EE passes a request through a list of filters, and Spring Security's filter chain does the same. The plain JDK has no ready-made base class for your own domain, so you write the one in this tutorial.

how-to-tutorialsdesign-patternsjava-tutorials

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments