The ticket says “insert a velocity check before fraud.” You open FraudHandler. Its constructor takes an InventoryHandler. That class takes a PaymentHandler. Payment takes NotifyHandler. Each step is a concrete type that names the next concrete type. Adding velocity means editing FraudHandler’s field, constructor, and the call at the bottom of handle. You also just re-ran inventory and payment tests for a change that has nothing to do with stock or cards.

That is Chain of Responsibility’s entire complaint. From the Design Patterns Roadmap: a request travels a chain until a handler takes it; no handler should hard-code the next class name.

This post stays on the shared Order / PaymentGateway / DiscountPolicy / OrderProcessor lab. We do not re-lecture the three families. We splice checkout steps because the sequence grows on someone else’s calendar — not because a review asked for a pattern name.

The sequence that names the next class

Here is checkout after fraud, hold, charge, and notify have each grown their own type. Nobody wrote this badly on purpose:

public final class FraudHandler {

    private final FraudService fraud;
    private final InventoryHandler next;

    public FraudHandler(FraudService fraud, InventoryHandler next) {
        this.fraud = fraud;
        this.next = next;
    }

    public void handle(Order order) {
        if (fraud.isBlocked(order)) {
            throw new CheckoutRejectedException(order.id(), "fraud");
        }
        next.handle(order);
    }
}

InventoryHandler looks the same with a PaymentHandler next. PaymentHandler holds PaymentGateway, DiscountPolicy, and a NotifyHandler. OrderProcessor is a one-liner that constructs the chain in a fixed order. Now price the velocity ticket — and the next two:

Insert velocity before fraud  -> edit FraudHandler’s type, ctor, and call
Skip notify on gift cards     -> PaymentHandler grows an if that names NotifyHandler
Test payment in isolation     -> construct fraud, inventory, and a notifier you do not use
Reorder hold before fraud     -> every constructor signature in the chain

Four costs, and none of them are about charging a card. Each step has become the owner of who is allowed to run after it.

Note: The problem is not a sequence. Checkout is a sequence. The problem is encoding “who is next” as a Java type on the previous class, so a new step is a compile-time edit in a file whose job is fraud, not plumbing.

A validation pipeline is the same smell with smaller methods: if (!emailOk) return; if (!addressOk) return; if (!stockOk) return; inside process. Each new rule edits the paid-order path. The chain below retires both shapes with one interface.

What Chain of Responsibility actually is

Three parts, one promise:

PartIn this labJob
HandlerCheckoutHandlersetNext / handle. Does not name a sibling class.
Concrete handlerFraudHandler, InventoryHandler, …One step. Pass, or stop.
ClientOrderProcessor (or the composition root)Sends the request to the head. Does not walk the list.

The next link is data you assign, not a type you import. If FraudHandler still declares InventoryHandler next, you have not chained; you have renamed a method call.

You do not need a class named Handler. You need a verb the pipeline already understands — here, “take this checkout request”:

public abstract class CheckoutHandler {

    private CheckoutHandler next;

    public final CheckoutHandler setNext(CheckoutHandler next) {
        this.next = next;
        return next;
    }

    public final void handle(CheckoutRequest request) {
        doHandle(request);
        if (request.shouldContinue() && next != null) {
            next.handle(request);
        }
    }

    protected abstract void doHandle(CheckoutRequest request);
}

That is the seam. setNext returns next so wiring reads left to right. handle is final so a subclass cannot “forget” to pass. Stopping is a decision on the request, not a missing super call.

The Gang of Four picture is “first capable handler consumes the request, the rest never run.” Logging levels and support-ticket routers look like that: canHandle is true, process, return. Checkout is the pipeline cousin: every step may work, and a reject is handling — the request stops. Same interface, different doHandle. Do not invent a second pattern name for “we still call next on success.”

One class per step

The request is a small mutable envelope around the lab Order. Handlers write a reject reason or a payment result; they do not throw from doHandle unless the process is truly broken (null gateway, not “card declined”):

public final class CheckoutRequest {

    private final Order order;
    private String rejectReason;

    public CheckoutRequest(Order order) {
        this.order = order;
    }

    public Order order() {
        return order;
    }

    public void reject(String reason) {
        this.rejectReason = reason;
    }

    public boolean shouldContinue() {
        return rejectReason == null;
    }

    public String rejectReason() {
        return rejectReason;
    }
}

Fraud and inventory no longer mention payment. They stop the chain by rejecting:

public final class FraudHandler extends CheckoutHandler {

    private final FraudService fraud;

    public FraudHandler(FraudService fraud) {
        this.fraud = fraud;
    }

    @Override
    protected void doHandle(CheckoutRequest request) {
        if (fraud.isBlocked(request.order())) {
            request.reject("fraud");
        }
    }
}

public final class InventoryHandler extends CheckoutHandler {

    private final Stock stock;

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

    @Override
    protected void doHandle(CheckoutRequest request) {
        if (!stock.hold(request.order())) {
            request.reject("out_of_stock");
        }
    }
}

Payment is the step that already belonged to OrderProcessor. It still uses DiscountPolicy and PaymentGateway. It does not import NotifyHandler:

public final class PaymentHandler extends CheckoutHandler {

    private final PaymentGateway gateway;
    private final DiscountPolicy discount;
    private final OrderRepository orders;

    public PaymentHandler(
            PaymentGateway gateway, DiscountPolicy discount, OrderRepository orders) {
        this.gateway = gateway;
        this.discount = discount;
        this.orders = orders;
    }

    @Override
    protected void doHandle(CheckoutRequest request) {
        Order order = request.order();
        PaymentResult result = gateway.charge(order, discount.payable(order));
        if (!result.approved()) {
            request.reject(result.failureReason());
            return;
        }
        orders.markPaid(order.id(), result.reference());
    }
}

Notify is one more handler, not a method PaymentHandler remembers to call. Gift-card checkout that skips notify is a shorter chain at the edge, not an if inside charge.

Note: Keep each handler dumb. The moment FraudHandler starts charging a card, or PaymentHandler starts asking “if it is a gift card, skip the next handler by type,” the forest has grown back. One concern per class. Who runs next is setNext.

The processor sends; it does not wire

OrderProcessor takes the head of the chain the same way it already takes a gateway — as a constructor argument. It does not name FraudHandler:

public class OrderProcessor {

    private final CheckoutHandler checkout;

    public OrderProcessor(CheckoutHandler checkout) {
        this.checkout = checkout;
    }

    public void process(Order order) {
        CheckoutRequest request = new CheckoutRequest(order);
        checkout.handle(request);
        if (!request.shouldContinue()) {
            throw new CheckoutRejectedException(order.id(), request.rejectReason());
        }
    }
}

Wiring lives at the composition root, next to secrets and feature flags. Velocity is a new class plus one extra setNext. Nothing in fraud’s source changes:

CheckoutHandler head = new VelocityHandler(velocity);
head.setNext(new FraudHandler(fraud))
        .setNext(new InventoryHandler(stock))
        .setNext(new PaymentHandler(gateway, discount, orders))
        .setNext(new NotifyHandler(notifier));
OrderProcessor processor = new OrderProcessor(head);

“You just moved the sequence” is the fair objection. Yes. The difference is which file owns it. The composition root is allowed to change every time product adds a step. FraudHandler is not.

What the diff looks like now

Same feature request, both designs:

Before — insert velocity
  M FraudHandler.java         new predecessor type, ctor, call
  M OrderProcessor.java       or whoever new’d FraudHandler
  M FraudHandlerTest.java     now constructs VelocityHandler too

After — insert velocity
  A VelocityHandler.java      new class, existing handlers untouched
  A VelocityHandlerTest.java  no gateway, no repository
  M CheckoutConfig.java       one extra setNext

One added file plus one line of wiring. FraudHandler is closed against “a new step appears” and still fully open when fraud rules change. Reordering hold before fraud is a swap of two setNext calls, not a signature change.

Proving a skip without charging a card

The seam pays a second dividend: you can assert that a reject stops the rest of the chain with fakes, and you can test PaymentHandler without a fraud service.

final class RecordingHandler extends CheckoutHandler {

    int calls;

    @Override
    protected void doHandle(CheckoutRequest request) {
        calls++;
    }
}

A unit test can now prove fraud short-circuits payment — no Stripe, no stock:

@Test
void blockedFraudDoesNotReachPayment() {
    FraudService fraud = order -> true;
    RecordingHandler payment = new RecordingHandler();
    CheckoutHandler head = new FraudHandler(fraud);
    head.setNext(payment);
    OrderProcessor processor = new OrderProcessor(head);
    Order order = new Order("o-1", "a@b.com", List.of(), new BigDecimal("40.00"));

    assertThrows(CheckoutRejectedException.class, () -> processor.process(order));
    assertEquals(0, payment.calls);
}

And PaymentHandler can be tested as a one-node chain with a FakePaymentGateway from the Strategy post — no FraudHandler in the fixture. If a declined-charge test has to construct inventory, you have mixed two reasons to change.

When the chain is the wrong move

Skip the handlers when:

  • There are two fixed steps that never grow. Validate, then gateway.charge. That is two method calls in process. A ValidateHandler plus ChargeHandler is two classes and a setNext for a sequence that has not moved in two years.
  • The order is the product invariant. Template Method already froze validate → provider → persist → notify in a superclass because that skeleton must not reorder. A chain that product can shuffle into “notify, then charge” is a bug, not flexibility. If the order is sacred, freeze it; do not setNext it.
  • Every request must run every step and none can stop. That is a list of functions you call in a for loop — or Observer, if the steps are independent reactions after a charge. A chain whose shouldContinue is always true is ceremony around for (CheckoutHandler h : steps) h.doHandle(request).
  • You are reaching for the name to look designed. Review comments that say “this should be a chain” without naming the third step that will arrive are unactionable.

The healthy trigger is a third step you can paste from a ticket — or a first-match router (support tiers, log levels) where who handles the request changes. Fraud vs velocity vs a future sanction-list check are three steps. Validate-then-charge is two method calls.

Chain of Responsibility also is not Decorator. Decorator wraps the same interface to add behavior (log, retry) and still exposes one object to the caller. The chain is several objects, each allowed to stop the pass. You can decorate PaymentGateway inside PaymentHandler. That wrap is not a link in this chain.

It is not Command either. Command makes the request an object you can queue or undo. The chain decides who runs. A ProcessOrderCommand whose execute() walks handlers is two patterns stacked; start with the one whose pain you have today.

Cheat sheet

Handler    CheckoutHandler           setNext / handle; next is a field, not a sibling type
Links      Fraud, Inventory, Payment, Notify   one class per step; reject stops the pass
Client     OrderProcessor            sends to the head; does not name FraudHandler
Wiring     CheckoutConfig            allowed to grow; lives at the edge

Trigger to apply: a third step (or a first-match router) is on a ticket
Trigger to stop:  two fixed method calls that never grow
Scoreboard:       new step = new file + one setNext; sibling handlers do not change
Not Template Method: order of steps is data here; skeleton is frozen there

Do:

  • Name the handler after the step (FraudHandler), not CheckoutHandlerImpl.
  • Put stop/continue on the request (or return a result). Do not rely on subclasses calling next.handle.
  • Test each step without the rest of the chain; test skip behavior with a recording successor.
  • Wire the chain once at the composition root. Do not rebuild it inside process.

Don’t:

  • Type a field as InventoryHandler next inside FraudHandler.
  • Throw from every reject if the processor already maps rejectReason to an exception — pick one style and keep it.
  • Build a four-class chain for validate-then-charge that has not gained a step in two years.
  • Ask next instanceof NotifyHandler to skip a link. Build a shorter chain instead.

Wrap-up

Chain of Responsibility is a handler that takes a successor of the same interface, and a client that only knows the head. The fraud-inventory-payment-notify path was expensive because every new step edited the previous class’s constructor. Moving next behind CheckoutHandler makes velocity an added class, keeps each step unit-testable, and leaves OrderProcessor free to change when the workflow result changes — how a reject becomes an exception, not who runs after fraud.

If the next pain is “callers still new a Stripe gateway and a Stripe webhook parser that must not drift apart,” that is Abstract Factory — a family of objects, not a sequence of steps.

Next optional step in the series Produce gateway, webhook parser, and money as one family per region. Abstract Factory: Families of Objects Without Naming the Concrete Types