Skip to content

Low Level DesignDesign patterns

Chain of responsibility pattern

The Chain of responsibility pattern through three real examples, from expense approvals to an HTTP request pipeline to a fraud decision chain, with diagrams, live demos and code in five languages.

Mid-level20 min read

A chain of responsibility passes a request along a line of handlers. Each handler looks at the request and either deals with it or passes it to the next one. The sender hands the request to the first handler and never learns how many there are, or which one answered until the answer comes back.

You have used it without the name. An exception travels up the call stack, until a catch block for its type takes it. A click event in the browser bubbles from the button up through its parent elements, until a listener calls stopPropagation(). A Java servlet filter either calls chain.doFilter(...), or writes a response and stops.

This page builds in three steps. First an approval chain. Then a request pipeline. Then a fraud decision chain that must stay fast, auditable, and safe to change while it runs.

The idea in one picture
expense E-2 $3,200 · travel sender knows only the first PolicyCheck banned category? TeamLead up to $500 Manager up to $5,000 takes it Director up to $50,000 CFO up to $250,000 never asked not mine: pass it on if the last one passes too: "unhandled", said out loud each handler checks one rule: take the request, or pass it to the next
Scroll or pinch to zoom, drag to pan
The request goes to the first handler. Each one checks its rule and either takes it or passes it on. Handlers after the taker never see it, and if nobody takes it, that is an outcome too.

Every chain has the same parts:

  • The handler interface has one method that takes the request, for example handle(expense).
  • Concrete handlers each know one rule. They handle the request, or pass it on.
  • The link to the next handler. Either each handler holds a next reference. Or a pipeline object holds the list, and hands each handler “the rest of the chain”.
  • The client holds the first handler (or the pipeline) and nothing else.
The general shape

holds the first

next

Client

«interface»

Handler

+handle(request) : Result

BaseHandler

-next: Handler

+handle(request) : Result

HandlerA

HandlerB

holds the first

next

Client

«interface»

Handler

+handle(request) : Result

BaseHandler

-next: Handler

+handle(request) : Result

HandlerA

HandlerB

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

The arrow from BaseHandler back to Handler looks like a decorator’s. The difference is in what a handler may do with it. A decorator always calls the object it wraps. A handler may answer, and never call next at all.

Variant Who acts Stops when Example on this page
First taker Exactly one handler decides The first handler that takes the request Easy, Hard
Pipeline Every handler may act, then pass the request on A handler answers, or the last one is reached Medium

The Gang of Four version links handlers to each other: each one holds next. That’s what the Easy level does, and it’s a good fit when the chain is built once in code. The other form keeps handlers in a list owned by a pipeline object, which calls them in order (the Medium and Hard levels). The order then lives in one place, handlers can be built from configuration, and one handler instance can sit in several pipelines. Most frameworks use the list form.

Easy

Expense approvals

  • The basic shape
  • Handle or pass on
  • An explicit "nobody took it"

A company reimburses expenses. A team lead can approve up to $500, a manager up to $5,000, a director up to $50,000 and the CFO up to $250,000. Gift cards are never reimbursable, whatever the amount. Anything above $250,000 needs the board, which is outside the system. A subsidiary has no managers or directors: its team leads hand everything bigger straight to the CFO.

One function holds every rule:

Approvals.javaexample.pyApprovals.csmain.goexample.ts
// Every approval rule lives in one function. Who approves what is buried in the order of
// the if statements, and the org chart is hard-coded into the code.
final class Approvals {
static String approve(int amount, String category) {
if (category.equals("gift cards")) return "rejected: gift cards are not reimbursable";
if (amount <= 500) return "approved by TeamLead";
if (amount <= 5_000) return "approved by Manager";
if (amount <= 50_000) return "approved by Director";
if (amount <= 250_000) return "approved by CFO";
return "unhandled: nobody can approve $" + amount;
}
// The subsidiary has no managers or directors, so it got its own copy of the function.
// Whoever wrote it forgot the gift card rule, and nothing tells them.
static String approveSubsidiary(int amount, String category) {
if (amount <= 500) return "approved by TeamLead";
if (amount <= 250_000) return "approved by CFO";
return "unhandled: nobody can approve $" + amount;
}
}
class Main {
public static void main(String[] args) {
System.out.println(Approvals.approve(3_200, "travel")); // approved by Manager
System.out.println(Approvals.approve(80, "gift cards")); // rejected: gift cards are not reimbursable
System.out.println(Approvals.approveSubsidiary(80, "gift cards")); // approved by TeamLead
}
}

The org chart is written into the order of the if statements. The subsidiary needed a different chain, so it got a copied function, and the copy lost the gift card rule. Nothing fails: an $80 gift card is simply approved. Every change to the org chart is now a change in two places, and the next subsidiary makes it three.

Make each approver an object that knows one rule and the next approver. It approves, rejects, or passes the expense on. The sender holds only the first approver:

Easy: approvers linked in a chain

holds the first

next

returns

ExpenseService

+submit(expense)

«abstract»

Approver

-next: Approver

+then(next) : Approver

+handle(expense) : Decision

#decide(expense) : Decision

PolicyCheck

-banned: Set

LimitApprover

-role

-limit

Decision

+verdict

+by

+reason

holds the first

next

returns

ExpenseService

+submit(expense)

«abstract»

Approver

-next: Approver

+then(next) : Approver

+handle(expense) : Decision

#decide(expense) : Decision

PolicyCheck

-banned: Set

LimitApprover

-role

-limit

Decision

+verdict

+by

+reason

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

Send an expense down the chain. Then move PolicyCheck below TeamLead, or take Manager out of the chain, and send it again:

The expense

The chain, first handler on top

end of chain

Pick an expense and send it.

Main.javalimit_approver.pyProgram.csapprover.golimit-approver.ts
import java.util.List;
import java.util.Set;
record Expense(String id, int amount, String category) {
public String toString() { return id + " $" + amount + " " + category; }
}
// What the chain returns. `by` names the handler that took the request, so every outcome
// can be explained. "Nobody took it" is a real outcome with its own value, never a null.
record Decision(String verdict, String by, String reason) {
static Decision unhandled(Expense e) {
return new Decision("unhandled", null, "nobody can approve $" + e.amount());
}
public String toString() {
if (by == null) return verdict + ": " + reason;
return verdict + " by " + by + (reason.isEmpty() ? "" : ": " + reason);
}
}
// Each handler knows one rule and the next handler. It either decides or passes the
// request on. The sender only ever talks to the first handler.
abstract class Approver {
private Approver next;
// Returns the handler it was given, so a chain reads left to right:
// policy.then(lead).then(manager)...
Approver then(Approver next) {
this.next = next;
return next;
}
// The passing logic lives here, once. Subclasses only answer "is this mine?", so no
// handler can forget to pass the request on.
final Decision handle(Expense e) {
Decision d = decide(e);
if (d != null) return d; // this handler took it: stop here
if (next != null) return next.handle(e); // not mine: pass it on
return Decision.unhandled(e); // end of the chain, and nobody took it
}
// null means "not mine".
protected abstract Decision decide(Expense e);
}
// A handler can take a request by refusing it. It still stops the chain.
class PolicyCheck extends Approver {
private final Set<String> banned;
PolicyCheck(Set<String> banned) { this.banned = banned; }
protected Decision decide(Expense e) {
if (!banned.contains(e.category())) return null;
return new Decision("rejected", "PolicyCheck", e.category() + " are not reimbursable");
}
}
// Four roles, one class, because they differ only in data. A role with its own logic
// (a travel desk that checks the destination, say) would be a class of its own.
class LimitApprover extends Approver {
private final String role;
private final int limit;
LimitApprover(String role, int limit) { this.role = role; this.limit = limit; }
protected Decision decide(Expense e) {
return e.amount() <= limit ? new Decision("approved", role, "") : null;
}
}
class Main {
static Approver policy() { return new PolicyCheck(Set.of("gift cards")); }
public static void main(String[] args) {
// The org chart is data, wired once at startup. The sender holds only the head.
Approver head = policy();
head.then(new LimitApprover("TeamLead", 500))
.then(new LimitApprover("Manager", 5_000))
.then(new LimitApprover("Director", 50_000))
.then(new LimitApprover("CFO", 250_000));
var travel = new Expense("E-2", 3_200, "travel");
var gift = new Expense("E-3", 80, "gift cards");
var expenses = List.of(new Expense("E-1", 120, "software"), travel, gift,
new Expense("E-4", 400_000, "equipment"));
for (Expense e : expenses) System.out.println(e + " -> " + head.handle(e));
// E-1 $120 software -> approved by TeamLead
// E-2 $3200 travel -> approved by Manager
// E-3 $80 gift cards -> rejected by PolicyCheck: gift cards are not reimbursable
// E-4 $400000 equipment -> unhandled: nobody can approve $400000
// The subsidiary has no managers or directors: same classes, a shorter chain, and
// the policy check comes along because it's a link, not a copied if statement.
Approver sub = policy();
sub.then(new LimitApprover("TeamLead", 500)).then(new LimitApprover("CFO", 250_000));
System.out.println(travel + " -> " + sub.handle(travel)); // E-2 $3200 travel -> approved by CFO
System.out.println(gift + " -> " + sub.handle(gift)); // E-3 $80 gift cards -> rejected by PolicyCheck: gift cards are not reimbursable
// Order is part of the design. With the policy check after the team lead, the team
// lead takes the gift cards first and the policy never sees them.
Approver wrong = new LimitApprover("TeamLead", 500);
wrong.then(policy()).then(new LimitApprover("CFO", 250_000));
System.out.println(gift + " -> " + wrong.handle(gift)); // E-3 $80 gift cards -> approved by TeamLead
}
}
  • The passing logic is written once. handle in the base class asks decide. It stops if there’s an answer, and otherwise calls next. Subclasses only answer “is this mine?”. So no handler can forget to pass the request on. Go has no inheritance, so the rule is a function, and Handle is a method on the one Approver struct. The structure is the same.
  • The org chart is data. The subsidiary’s chain is the same classes, linked differently. The policy check comes along, because it’s a link, not a copied if. A new role is a new link, and no existing handler changes. That’s the open-closed principle applied to routing.
  • Refusing is handling. PolicyCheck takes gift cards by rejecting them. The chain stops either way. “Handled” means “decided”, not “approved”.
  • “Nobody took it” is a value. At the end of the chain handle returns an unhandled decision that says why. Returning null, or approving by default, would let a $400,000 expense through quietly or crash somewhere far from the cause.
  • Order matters. With PolicyCheck after TeamLead, the team lead takes the $80 gift card first and the policy never sees it. Rules that must always apply go at the front.
  • One class, four roles. The four limit approvers differ only in data, so they’re one class. When every handler is like that, a plain list of (role, limit) pairs and a loop is enough. See Commonly confused with.
Interviewer

Expenses go to a team lead, then a manager, then a director, depending on the amount. The subsidiary has no directors. Design the approval flow.

What they are testing

Whether you make each approver a handler that decides or passes on. Whether you build each department’s chain from the same parts. And whether you give “nobody can approve” its own outcome. The red flag is a growing if ladder, copied per department. So is a chain whose end returns null.

Say this out loud

Each approver is a handler, with one rule and a link to the next. It approves, rejects, or passes the expense on. The service holds only the first one. Each department wires its own chain from the same classes. And if nobody takes it, the chain returns “unhandled” with a reason, instead of null.

Medium

An HTTP request pipeline

  • Every handler may act
  • Short-circuit responses
  • Building the chain from config
  • Order mistakes
  • Testing one handler

An orders API has endpoints for creating and cancelling orders. Before the endpoint runs, every request must pass three checks. It must be authenticated. It must be rate limited. (The examples allow two requests per key, so the effect shows quickly. A real limiter counts per time window.) And it must have a valid body. Each of those checks can answer by itself: 401, 429 or 400. The office’s employees all reach the API through one public IP address.

Each endpoint does its own checks, inline:

OrdersApi.javaorders_api.pyOrdersApi.csorders_api.goorders-api.ts
import java.util.HashMap;
import java.util.Map;
// Every endpoint does its own checks, inline, before its real work. The checks and their
// order are copied from endpoint to endpoint.
final class OrdersApi {
private final Map<String, String> tokens = Map.of("t-alice", "alice", "t-bob", "bob");
private final Map<String, Integer> counts = new HashMap<>();
String createOrder(String token, int qty) {
if (token == null) return "401 missing token";
String user = tokens.get(token);
if (user == null) return "401 invalid token";
if (counts.merge(user, 1, Integer::sum) > 2) return "429 slow down";
if (qty < 1 || qty > 99) return "400 qty must be 1..99";
return "201 order for " + user;
}
// Copied from createOrder, minus the rate limit, which nobody noticed was missing.
// Adding a check (say, blocking suspended accounts) means editing every endpoint.
String cancelOrder(String token, String orderId) {
if (token == null) return "401 missing token";
String user = tokens.get(token);
if (user == null) return "401 invalid token";
return "200 cancelled " + orderId;
}
}
class Main {
public static void main(String[] args) {
var api = new OrdersApi();
System.out.println(api.createOrder(null, 1)); // 401 missing token
System.out.println(api.createOrder("t-alice", 0)); // 400 qty must be 1..99
for (int i = 0; i < 3; i++) System.out.println(api.cancelOrder("t-alice", "O-" + i));
// 200 cancelled O-0
// 200 cancelled O-1
// 200 cancelled O-2
}
}

cancelOrder was copied from createOrder and lost its rate limit. Nobody notices, because nothing fails. Adding a check for suspended accounts means editing every endpoint, and the order of the checks is whatever each author typed.

Make each check a handler that receives the request and the rest of the chain. It answers (and the request goes no further) or calls next. The endpoint is just the last handler. A Pipeline holds the handlers in order and builds them from a list of names in configuration:

Medium: a pipeline of handlers built from config

calls in order

*

reads, auth sets user

may answer

Pipeline

-handlers: List<Handler>

+fromConfig(names, registry, endpoint) : Pipeline

+send(request) : Response

«interface»

Handler

+handle(request, next) : Response

Auth

-tokens

RateLimit

-limit

-counts

Validate

CreateOrder

Request

+ip

+token

+qty

+user

Response

+status

+by

+message

calls in order

*

reads, auth sets user

may answer

Pipeline

-handlers: List<Handler>

+fromConfig(names, registry, endpoint) : Pipeline

+send(request) : Response

«interface»

Handler

+handle(request, next) : Response

Auth

-tokens

RateLimit

-limit

-counts

Validate

CreateOrder

Request

+ip

+token

+qty

+user

Response

+status

+by

+message

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

Send requests and watch which handler answers. Then move rateLimit above auth and send alice and bob again:

Pipeline, first handler on top

  1. auth401 without a valid token; sets user
  2. rateLimit429 after 2 requests per key
  3. validate400 unless qty is 1..99
  4. createOrderthe endpoint: 201, never calls next

    Send a request

    alice and bob both come from the office IP 10.0.0.7.

    Rate limit counters (limit 2)

    none yet

    Responses, newest first

      Main.javapipeline.pyProgram.cspipeline.gopipeline.ts
      import java.util.ArrayList;
      import java.util.HashMap;
      import java.util.List;
      import java.util.Map;
      import java.util.TreeSet;
      import java.util.function.Function;
      import java.util.function.Supplier;
      // The request as the pipeline sees it. `user` starts empty and auth fills it in, so every
      // handler after auth can rely on it and no handler before auth can. That is why order
      // matters in this pipeline.
      final class Request {
      final String label, ip, token;
      final int qty;
      String user;
      Request(String label, String ip, String token, int qty) {
      this.label = label; this.ip = ip; this.token = token; this.qty = qty;
      }
      }
      // Who answered is part of the response, so logs and tests can say which handler stopped it.
      record Response(int status, String by, String message) {
      public String toString() { return status + " by " + by + " (" + message + ")"; }
      }
      // A handler gets the request and the rest of the chain as a function. It answers, and the
      // chain stops there, or it calls next. It never knows what comes next, so one class works
      // in any pipeline, at any position.
      interface Handler {
      Response handle(Request req, Function<Request, Response> next);
      }
      class Auth implements Handler {
      private final Map<String, String> tokens; // token -> user
      Auth(Map<String, String> tokens) { this.tokens = tokens; }
      public Response handle(Request req, Function<Request, Response> next) {
      if (req.token == null) return new Response(401, "auth", "missing token");
      String user = tokens.get(req.token);
      if (user == null) return new Response(401, "auth", "invalid token");
      req.user = user; // handlers after this one can trust it
      return next.apply(req);
      }
      }
      // Keys by user when auth has already run, otherwise by IP. Which key it gets depends only
      // on where it sits in the chain.
      class RateLimit implements Handler {
      private final int limit;
      private final Map<String, Integer> counts = new HashMap<>();
      RateLimit(int limit) { this.limit = limit; }
      public Response handle(Request req, Function<Request, Response> next) {
      String key = req.user != null ? "user:" + req.user : "ip:" + req.ip;
      if (counts.merge(key, 1, Integer::sum) > limit)
      return new Response(429, "rateLimit", "over " + limit + " for " + key);
      return next.apply(req);
      }
      }
      class Validate implements Handler {
      public Response handle(Request req, Function<Request, Response> next) {
      if (req.qty < 1 || req.qty > 99) return new Response(400, "validate", "qty must be 1..99");
      return next.apply(req);
      }
      }
      // The endpoint is just the last handler: it never calls next.
      class CreateOrder implements Handler {
      public Response handle(Request req, Function<Request, Response> next) {
      return new Response(201, "createOrder", "order for " + req.user);
      }
      }
      // Holds the handlers in order and hands each one "the rest of the chain". The order lives
      // here, in one list built from config, not in fields scattered across handlers.
      final class Pipeline {
      private final List<Handler> handlers;
      private Pipeline(List<Handler> handlers) { this.handlers = List.copyOf(handlers); }
      // Unknown names fail when the pipeline is built, at startup, not on the first request.
      // Each pipeline gets fresh instances, so a rate limiter's counters belong to one pipeline.
      static Pipeline fromConfig(List<String> names, Map<String, Supplier<Handler>> registry, Handler endpoint) {
      List<Handler> hs = new ArrayList<>();
      for (String name : names) {
      Supplier<Handler> make = registry.get(name);
      if (make == null)
      throw new IllegalArgumentException("unknown handler '" + name + "' (known: "
      + String.join(", ", new TreeSet<>(registry.keySet())) + ")");
      hs.add(make.get());
      }
      hs.add(endpoint);
      return new Pipeline(hs);
      }
      Response send(Request req) { return at(0, req); }
      private Response at(int i, Request req) {
      // Falling off the end means the chain has no endpoint. Say so instead of returning null.
      if (i == handlers.size()) return new Response(500, "pipeline", "no handler answered");
      return handlers.get(i).handle(req, r -> at(i + 1, r));
      }
      }
      class Main {
      static final String OFFICE = "10.0.0.7"; // alice and bob share the office's public IP
      static Request alice(int qty) { return new Request(qty == 0 ? "alice: qty 0" : "alice: valid", OFFICE, "t-alice", qty); }
      static Request bob() { return new Request("bob: valid", OFFICE, "t-bob", 2); }
      static Request anon() { return new Request("anon: no token", "203.0.113.5", null, 2); }
      static void run(Pipeline p, List<Request> reqs) {
      for (Request r : reqs) System.out.println(String.format("%-15s -> %s", r.label, p.send(r)));
      }
      public static void main(String[] args) {
      Map<String, String> tokens = Map.of("t-alice", "alice", "t-bob", "bob");
      Map<String, Supplier<Handler>> registry = Map.of(
      "auth", () -> new Auth(tokens),
      "rateLimit", () -> new RateLimit(2),
      "validate", Validate::new);
      System.out.println("auth -> rateLimit -> validate -> createOrder");
      Pipeline good = Pipeline.fromConfig(List.of("auth", "rateLimit", "validate"), registry, new CreateOrder());
      run(good, List.of(alice(2), anon(), alice(0), alice(2), bob()));
      // alice: valid -> 201 by createOrder (order for alice)
      // anon: no token -> 401 by auth (missing token)
      // alice: qty 0 -> 400 by validate (qty must be 1..99)
      // alice: valid -> 429 by rateLimit (over 2 for user:alice)
      // bob: valid -> 201 by createOrder (order for bob)
      // Same handlers, rate limit first. It runs before auth, so it can only key by IP,
      // and everyone in the office shares one quota. Bob is throttled for Alice's requests.
      System.out.println("rateLimit -> auth -> validate -> createOrder");
      Pipeline swapped = Pipeline.fromConfig(List.of("rateLimit", "auth", "validate"), registry, new CreateOrder());
      run(swapped, List.of(alice(2), bob(), bob()));
      // alice: valid -> 201 by createOrder (order for alice)
      // bob: valid -> 201 by createOrder (order for bob)
      // bob: valid -> 429 by rateLimit (over 2 for ip:10.0.0.7)
      try {
      Pipeline.fromConfig(List.of("auth", "cors"), registry, new CreateOrder());
      } catch (IllegalArgumentException e) {
      System.out.println(e.getMessage()); // unknown handler 'cors' (known: auth, rateLimit, validate)
      }
      // A handler is testable alone: pass a fake next and check whether it was called.
      boolean[] called = {false};
      Response r = new Auth(tokens).handle(anon(), req -> { called[0] = true; return null; });
      System.out.println("auth alone: " + r.status() + ", next called: " + called[0]); // auth alone: 401, next called: false
      }
      }
      • This is the pipeline variant. In the Easy level, one approver decided, and the rest never saw the expense. Here each handler may do its part, and pass the request on. auth checks the token and records who the user is. rateLimit counts. validate checks the body. Any of them can answer instead, and the request goes no further.
      • Handlers don’t know their neighbours. next is passed in, not stored, so the order lives in one list. The same Auth class serves every endpoint, and cancelOrder can’t lose its rate limit because it no longer has its own copy of the checks.
      • Built from configuration, checked at startup. fromConfig turns names into handlers through a registry and throws for an unknown name, listing the known ones. A typo in a config file fails the deploy, not the first customer request. Each pipeline gets fresh instances, so two pipelines don’t share a rate limiter’s counters by accident.
      • Order mistakes are behavior changes, not crashes. rateLimit reads user, which auth sets. Put it first and it can only key by IP, so everyone in the office shares one quota and Bob is refused for Alice’s requests. Put validate before auth and a caller with no token learns your validation rules from a 400 instead of getting a 401. Both pipelines run without an error.
      • The endpoint is a handler too. It never calls next. If the list ends without an answer, the pipeline returns a 500 saying so, rather than null.
      • One handler can be tested alone. Call Auth.handle with a fake next that records whether it ran. The test proves “no token means 401 and nothing after auth runs” without building the pipeline.
      • Before and after is a decorator’s job. These handlers only act before calling next. Some handlers also run code after next returns: timing the request, adding a header, logging the status. That handler is wrapping the rest of the chain. That’s the decorator’s shape, with its ordering rules. Express, ASP.NET Core and Go middleware let a handler do both. That’s why the two patterns blur there.
      Interviewer

      Our API checks the token, the rate limit and the body inside every endpoint, and one endpoint was shipped without the rate limit. How would you restructure it?

      What they are testing

      Whether you pull the checks into handlers that answer or call next. Whether you build one pipeline, in one place. And whether you reason about order from data dependencies (rate limit by user needs auth first). The red flag is a shared helper function each endpoint must remember to call. So is not noticing that order changes behavior.

      Hard

      A fraud decision chain

      • Ordering by cost and hit rate
      • Per-check timeouts
      • Fail open or closed, per check
      • An audit trail
      • Hot reload without dropping requests

      A checkout decides for every card payment whether to approve it, decline it, or hold it for a person to review. It runs thousands of payments a second, and each decision is on the customer’s critical path. Four checks exist:

      • A blocklist of known stolen cards. It’s in memory, and effectively free.
      • A velocity check. It counts attempts per card in a shared store (one round trip, about 1 ms), and declines from the fourth attempt. That stops card testing: fraudsters trying many small charges, to find which stolen cards work.
      • A risk model. It’s a remote service that returns a score in 15 to 40 ms on a good day, and sometimes much slower.
      • Manual review. It holds unclear payments of $500 or more for a person. It approves smaller ones, because a review costs more than the risk.

      Every decision must be explainable months later, when a customer disputes a charge or an auditor asks why a payment went through. And the fraud team changes thresholds and failure policies several times a day, during incidents, without a deploy.

      A first-taker chain fits. Blocklist, velocity and model each decide when they’re sure, and pass otherwise. Review always decides, so nothing falls off the end. The hard parts are what a senior engineer owns. The order. The time each check may take. What happens when one fails. The record of who decided. And changing the chain while payments are in flight.

      The demo runs a fixed batch of 1,000 payments. It’s ordinary traffic with a few percent fraud, a handful of blocklisted cards, and a card-testing burst: 20 stolen cards with 14 small charges each. Run it once. Then move model to the top. Then slow the model down, and switch between failing open and closed:

      Chain, first check on top

      1. blocklistdeclines listed cards · 0 ms
      2. velocitydeclines from the 4th try per card · 1 ms
      3. modeldeclines ≥ 0.80, approves < 0.30 · 50 ms budget
      4. reviewholds $500 or more for a person · approves the rest

      Model service

      When the model times out

      Per check

      CheckReachedDecidedAvg ms

      Runs, newest first

        With the cheap checks first, the model sees 760 of the 1,000 payments. A payment costs 22.1 ms on average. With the model first, it sees all 1,000, and the average rises to 27.9 ms. Fraud approvals rise too, from 53 to 70. That’s because velocity now only counts attempts that the model passed. So a stolen card reaches its fourth counted attempt later.

        Now slow the model past its 50 ms budget. Failing open sends 516 timed-out payments onward. The review queue nearly triples (44 to 130 held). Failing closed declines them instead. Fraud approvals fall to 20, and 475 good customers are declined.

        Hard: an immutable chain behind an atomic holder

        live version

        *

        shared, outside the chain

        records

        ChainHolder

        -current: AtomicReference<Chain>

        +current() : Chain

        +swap(chain)

        Chain

        +version

        -links: List<Link>

        +decide(payment) : Decision

        Link

        +check

        +timeoutMs

        +onFailure

        «enumeration»

        OnFailure

        OPEN

        CLOSED

        «interface»

        Check

        +name()

        +check(payment, budget) : Opinion

        Blocklist

        Velocity

        RiskModel

        ManualReview

        AttemptCounter

        +record(card) : int

        Decision

        +verdict

        +by

        +reason

        +version

        +trail

        live version

        *

        shared, outside the chain

        records

        ChainHolder

        -current: AtomicReference<Chain>

        +current() : Chain

        +swap(chain)

        Chain

        +version

        -links: List<Link>

        +decide(payment) : Decision

        Link

        +check

        +timeoutMs

        +onFailure

        «enumeration»

        OnFailure

        OPEN

        CLOSED

        «interface»

        Check

        +name()

        +check(payment, budget) : Opinion

        Blocklist

        Velocity

        RiskModel

        ManualReview

        AttemptCounter

        +record(card) : int

        Decision

        +verdict

        +by

        +reason

        +version

        +trail

        Scroll or pinch to zoom, drag to pan
        • Color key
        • Facade
        • Entity
        • Value object
        • Enum
        • Interface
        • Implementation
        A reload lands while a payment is in flight
        Chain v2Fraud teamChain v1ChainHolderPayment P-6Chain v2Fraud teamChain v1ChainHolderPayment P-6new payments read v2. v1 is freed when its last payment ends.current()v1, kept for the whole paymentbuild the whole chainswap(v2)decide(P-6)blocklist pass, velocity passmodel: timeout at 50 ms, fails openreview: approve (small amount)APPROVE by review (v1) and the trailChain v2Fraud teamChain v1ChainHolderPayment P-6Chain v2Fraud teamChain v1ChainHolderPayment P-6new payments read v2. v1 is freed when its last payment ends.current()v1, kept for the whole paymentbuild the whole chainswap(v2)decide(P-6)blocklist pass, velocity passmodel: timeout at 50 ms, fails openreview: approve (small amount)APPROVE by review (v1) and the trail
        Scroll or pinch to zoom, drag to pan
        • Color key
        • Entity
        • Facade
        Main.javachain_holder.pyProgram.cschain_holder.gochain-holder.ts
        import java.util.ArrayList;
        import java.util.List;
        import java.util.Set;
        import java.util.concurrent.ConcurrentHashMap;
        import java.util.concurrent.ConcurrentLinkedQueue;
        import java.util.concurrent.ExecutorService;
        import java.util.concurrent.Executors;
        import java.util.concurrent.Future;
        import java.util.concurrent.atomic.AtomicInteger;
        import java.util.concurrent.atomic.AtomicReference;
        import java.util.stream.Collectors;
        enum Verdict { APPROVE, DECLINE, HOLD } // HOLD: wait for a person to look
        // The model's score and latency are part of the test data, so every run prints the same.
        record Payment(String id, String card, int amount, double score, int modelMs) {}
        // What a check says. A check returns null for "no opinion: ask the next one".
        record Opinion(Verdict verdict, String reason) {}
        // A check that can't answer: a timeout, or a dependency that is down.
        class CheckFailed extends Exception {
        CheckFailed(String message) { super(message); }
        }
        // The time one check may spend. Real code hands the deadline to the client it calls
        // (Future.get with a timeout, an HTTP client timeout); this one counts fake milliseconds.
        final class Budget {
        final int limitMs;
        int usedMs;
        Budget(int limitMs) { this.limitMs = limitMs; }
        void spend(int ms) throws CheckFailed {
        if (usedMs + ms > limitMs) {
        usedMs = limitMs; // we waited the whole budget, then gave up
        throw new CheckFailed("timeout after " + limitMs + " ms");
        }
        usedMs += ms;
        }
        }
        // One link's logic. Checks are shared by every request thread, so they must be safe to call
        // concurrently: no per-request fields, and shared state only in thread-safe stores.
        interface Check {
        String name();
        Opinion check(Payment p, Budget budget) throws CheckFailed;
        }
        class Blocklist implements Check {
        private final Set<String> cards;
        Blocklist(Set<String> cards) { this.cards = Set.copyOf(cards); }
        public String name() { return "blocklist"; }
        public Opinion check(Payment p, Budget budget) {
        return cards.contains(p.card()) ? new Opinion(Verdict.DECLINE, "card on blocklist") : null;
        }
        }
        // Attempts per card. It lives outside the chain, so swapping in a new chain keeps the counts,
        // and every request thread writes to it, so each increment is atomic.
        final class AttemptCounter {
        private final ConcurrentHashMap<String, AtomicInteger> attempts = new ConcurrentHashMap<>();
        int record(String card) { return attempts.computeIfAbsent(card, k -> new AtomicInteger()).incrementAndGet(); }
        }
        class Velocity implements Check {
        private final AttemptCounter counter;
        private final int max;
        Velocity(AttemptCounter counter, int max) { this.counter = counter; this.max = max; }
        public String name() { return "velocity"; }
        public Opinion check(Payment p, Budget budget) throws CheckFailed {
        budget.spend(1); // one round trip to the shared counter store
        int n = counter.record(p.card());
        return n > max ? new Opinion(Verdict.DECLINE, n + " attempts on this card") : null;
        }
        }
        // Decides only when it is confident. The middle band is "no opinion".
        class RiskModel implements Check {
        private final double declineAt, approveBelow;
        RiskModel(double declineAt, double approveBelow) { this.declineAt = declineAt; this.approveBelow = approveBelow; }
        public String name() { return "model"; }
        public Opinion check(Payment p, Budget budget) throws CheckFailed {
        budget.spend(p.modelMs()); // the remote call; throws if it takes longer than the budget
        String why = String.format("score %.2f", p.score());
        if (p.score() >= declineAt) return new Opinion(Verdict.DECLINE, why);
        if (p.score() < approveBelow) return new Opinion(Verdict.APPROVE, why);
        return null;
        }
        }
        // The last check always decides, so nothing falls off the end. Unclear large payments go
        // to a person; small ones are approved, because a review costs more than the risk.
        class ManualReview implements Check {
        private final int reviewFrom;
        private final ConcurrentLinkedQueue<String> queue;
        ManualReview(int reviewFrom, ConcurrentLinkedQueue<String> queue) { this.reviewFrom = reviewFrom; this.queue = queue; }
        public String name() { return "review"; }
        public Opinion check(Payment p, Budget budget) throws CheckFailed {
        if (p.amount() < reviewFrom) return new Opinion(Verdict.APPROVE, "small amount, no clear signal");
        budget.spend(2); // insert into the review queue
        queue.add(p.id());
        return new Opinion(Verdict.HOLD, "$" + p.amount() + " with no clear signal");
        }
        }
        // Fail open: treat a failure as "no opinion" and ask the next check.
        // Fail closed: a failure declines the payment.
        enum OnFailure { OPEN, CLOSED }
        // Where a check sits and how it fails are chain configuration, not check logic, so the
        // same model check can fail open in one chain version and closed in the next.
        record Link(Check check, int timeoutMs, OnFailure onFailure) {}
        record Step(String check, String outcome, int ms) {
        public String toString() { return check + " " + outcome + " " + ms + "ms"; }
        }
        // Kept for audits and disputes: what was decided, by which check, why, under which chain
        // version, and everything each check said on the way.
        record Decision(String id, Verdict verdict, String by, String reason, int version, List<Step> trail) {
        public String toString() { return id + " " + verdict + " by " + by + ": " + reason + " (v" + version + ")"; }
        String trailLine() { return " " + trail.stream().map(Step::toString).collect(Collectors.joining(" > ")); }
        }
        // Immutable: built whole and never edited, so any number of threads can run it at once
        // without locks. A reload builds a new Chain instead of changing this one.
        final class Chain {
        final int version;
        private final List<Link> links;
        Chain(int version, List<Link> links) { this.version = version; this.links = List.copyOf(links); }
        Decision decide(Payment p) {
        List<Step> trail = new ArrayList<>();
        for (Link link : links) {
        String name = link.check().name();
        Budget budget = new Budget(link.timeoutMs());
        Opinion opinion;
        String outcome;
        try {
        opinion = link.check().check(p, budget);
        outcome = opinion == null ? "pass" : opinion.verdict().name().toLowerCase();
        } catch (CheckFailed f) {
        if (link.onFailure() == OnFailure.CLOSED) {
        opinion = new Opinion(Verdict.DECLINE, f.getMessage() + ", failing closed");
        outcome = "failed closed";
        } else {
        opinion = null;
        outcome = "failed open";
        }
        }
        trail.add(new Step(name, outcome, budget.usedMs));
        if (opinion != null) return new Decision(p.id(), opinion.verdict(), name, opinion.reason(), version, List.copyOf(trail));
        }
        // Only reachable if the last check can pass, which is a config bug. Fail safe.
        return new Decision(p.id(), Verdict.HOLD, "chain", "no check decided", version, List.copyOf(trail));
        }
        }
        // Holds the live chain. Each payment reads it once and keeps that snapshot to the end;
        // a reload publishes a fully built chain in one atomic write. Nothing is locked or drained.
        final class ChainHolder {
        private final AtomicReference<Chain> current;
        ChainHolder(Chain first) { current = new AtomicReference<>(first); }
        Chain current() { return current.get(); }
        void swap(Chain next) { current.set(next); }
        }
        class Main {
        static final AttemptCounter counter = new AttemptCounter();
        static final ConcurrentLinkedQueue<String> reviewQueue = new ConcurrentLinkedQueue<>();
        // Cheap, high-yield checks first; the slow model only sees what they let through.
        static Chain build(int version, OnFailure modelOnFailure) {
        return new Chain(version, List.of(
        new Link(new Blocklist(Set.of("c-666")), 5, OnFailure.CLOSED), // in memory: if it can't answer, stop
        new Link(new Velocity(counter, 3), 10, OnFailure.OPEN), // a counter outage shouldn't stop all sales
        new Link(new RiskModel(0.80, 0.30), 50, modelOnFailure),
        new Link(new ManualReview(500, reviewQueue), 20, OnFailure.CLOSED)));
        }
        static void show(Decision d) {
        System.out.println(d);
        System.out.println(d.trailLine());
        }
        public static void main(String[] args) throws Exception {
        ChainHolder holder = new ChainHolder(build(1, OnFailure.OPEN));
        show(holder.current().decide(new Payment("P-1", "c-111", 40, 0.10, 30)));
        // P-1 APPROVE by model: score 0.10 (v1)
        // blocklist pass 0ms > velocity pass 1ms > model approve 30ms
        show(holder.current().decide(new Payment("P-2", "c-666", 900, 0.20, 30)));
        // P-2 DECLINE by blocklist: card on blocklist (v1)
        // blocklist decline 0ms
        show(holder.current().decide(new Payment("P-3", "c-222", 700, 0.55, 25)));
        // P-3 HOLD by review: $700 with no clear signal (v1)
        // blocklist pass 0ms > velocity pass 1ms > model pass 25ms > review hold 2ms
        show(holder.current().decide(new Payment("P-4", "c-333", 120, 0.95, 80)));
        // P-4 APPROVE by review: small amount, no clear signal (v1)
        // blocklist pass 0ms > velocity pass 1ms > model failed open 50ms > review approve 0ms
        // Card testing: many small charges on one stolen card. The cheap velocity check
        // stops it from the fourth attempt on, before the model is ever called.
        Decision last = null;
        for (int i = 1; i <= 4; i++) last = holder.current().decide(new Payment("T-" + i, "c-444", 1, 0.50, 20));
        show(last);
        // T-4 DECLINE by velocity: 4 attempts on this card (v1)
        // blocklist pass 0ms > velocity decline 1ms
        // Hot reload. A payment already running holds its snapshot of v1...
        Chain inFlight = holder.current();
        // ...while the team flips the model to fail closed. v2 is built whole, then swapped in.
        holder.swap(build(2, OnFailure.CLOSED));
        System.out.println(holder.current().decide(new Payment("P-5", "c-555", 120, 0.95, 80)));
        // P-5 DECLINE by model: timeout after 50 ms, failing closed (v2)
        System.out.println(inFlight.decide(new Payment("P-6", "c-777", 120, 0.95, 80)));
        // P-6 APPROVE by review: small amount, no clear signal (v1)
        // State lives outside the chain, so the swap kept the attempt counts.
        System.out.println(holder.current().decide(new Payment("T-5", "c-444", 1, 0.50, 20)));
        // T-5 DECLINE by velocity: 5 attempts on this card (v2)
        // Eight threads share one chain with no locks. Only the counter is shared state,
        // and its atomic increments let exactly 3 attempts through.
        ExecutorService pool = Executors.newFixedThreadPool(8);
        List<Future<List<Decision>>> futures = new ArrayList<>();
        for (int t = 0; t < 8; t++) {
        futures.add(pool.submit(() -> {
        List<Decision> out = new ArrayList<>();
        for (int i = 0; i < 100; i++) out.add(holder.current().decide(new Payment("X", "c-999", 1, 0.50, 20)));
        return out;
        }));
        }
        int approved = 0, byVelocity = 0;
        for (Future<List<Decision>> f : futures) {
        for (Decision d : f.get()) {
        if (d.verdict() == Verdict.APPROVE) approved++;
        if (d.by().equals("velocity")) byVelocity++;
        }
        }
        pool.shutdown();
        System.out.println("800 payments on 8 threads: " + approved + " approved, " + byVelocity + " declined by velocity");
        // 800 payments on 8 threads: 3 approved, 797 declined by velocity
        System.out.println("review queue: " + reviewQueue); // review queue: [P-3]
        }
        }
        • Order by cost, and by how much each check settles. The average cost of a chain is c1 + (1 - p1)·c2 + (1 - p1)(1 - p2)·c3 + .... Here c is a check’s cost, and p the share of what reaches it that it decides. A cheap check that settles many requests should come early. Say the checks are independent, and none changes another’s answer. Then sorting by c / p, from smallest to largest, gives the lowest average. That condition matters. A model that can approve must not run before the blocklist, however the numbers look. And a stateful check like velocity only counts what reaches it.
        • Each check gets its own time budget. The chain gives every link a Budget. The model gets 50 ms. The blocklist gets 5 ms. Without per-check budgets, one slow dependency spends the whole payment’s time. Then the request times out somewhere upstream, with no record of which check was slow. The code counts fake milliseconds, so the output is the same on every run. Real code passes the deadline to the client it calls (Future.get with a timeout, a context.Context, a CancellationToken, an AbortSignal).
        • Fail open or closed is decided per check, in the chain’s config. The check only reports that it failed. The Link says what that means. The velocity store being down shouldn’t stop all sales (open). But an in-memory blocklist that can’t answer means something is badly wrong (closed). The model’s policy is the hard one, and the demo shows both costs. Either fraud gets through and the review queue floods, or good customers are declined. That’s why it’s a config value the fraud team can flip during an incident, not a constant in the code.
        • Every decision records who decided, and why. Decision holds the verdict, the check that decided, its reason, and the chain version. It also holds a trail of what every check said, and how long it took. That record answers disputes and audits. The same data, counted per check, is the dashboard that shows when a check stops earning its place.
        • Hot reload swaps a whole chain. A Chain is immutable, and its links are copied when it’s built. A reload builds the new chain completely. Then it publishes it with one atomic write (AtomicReference.set, Volatile.Write, atomic.Pointer.Store, or a single reference assignment in Python and TypeScript). Each payment reads the holder once, and keeps that snapshot. So it runs start to finish on one version. P-6 finishes on v1, while P-5 already runs on v2. Nothing is locked on the request path, and nothing is drained.
        • State lives outside the chain. The attempt counts and the review queue are separate objects that the checks are given. Swapping the chain keeps them: card c-444 is on its fifth attempt under v2. If the counter lived inside Velocity, every reload would forgive every card.
        • Shared state is the only thing that needs care under concurrency. Eight threads run 800 payments on one card, through one chain, with no locks. The chain is immutable. Each payment’s Budget and trail are its own. Only the counter is shared, so its increment is atomic (ConcurrentHashMap with AtomicInteger, Interlocked.Increment, a mutex in Go, a lock in Python). Exactly three attempts get through, on every run. Node has one thread. So the TypeScript version runs the 800 payments one after another, and needs no lock.
        • The last check always decides. ManualReview never passes, so “nobody decided” can only happen through a configuration mistake. The chain still handles it: it holds the payment rather than approving it.
        Interviewer

        Our fraud model sometimes takes two seconds to answer, and checkout times out. Also, the fraud team wants to change thresholds during incidents without a deploy. Design the decision flow.

        What they are testing

        Whether you order cheap, decisive checks first. Whether you give each check its own timeout. Whether you make fail open or closed a per-check decision, with its costs named. Whether you record which check decided, and under which version. And whether you reload by swapping an immutable chain that in-flight requests keep using. The red flags: a global timeout around the whole flow, a chain edited in place under a lock, or counters that reset on reload.

        Say this out loud

        It’s a first-taker chain: blocklist, velocity, model, then review, which always decides. Cheap checks that settle a lot go first. Each link has its own timeout, and its own fail open or closed policy. Every decision records the check, the reason, the trail and the chain version. A reload builds a new immutable chain, and swaps one reference. Each payment keeps the version it started with. Counters live outside the chain.

        Use a chain in three cases. Several handlers might deal with a request, and which one should is decided at run time. The set or order of handlers varies by configuration, tenant or department. Or the same checks must run in front of many endpoints. Keep a plain function when there are two or three fixed rules that never change. And use a plain list of rules with a loop, when the rules are just data.

        Language Usual form Built in
        Java An abstract handler with next, or a list of handlers called by index Servlet FilterChain (chain.doFilter), Spring Security’s SecurityFilterChain, java.util.logging passing records to parent handlers
        Python A list of callables or objects and a loop; middleware that wraps the next callable logging propagating records to parent loggers, WSGI and Django middleware
        C# Middleware delegates (context, next), or Func<Request, Response> passed in ASP.NET Core middleware (app.Use, which may short-circuit by not calling next)
        Go A slice of handlers, or func(http.Handler) http.Handler middleware net/http handlers (a middleware chain is a convention, not a library type)
        TypeScript Functions (req, res, next) in an array; handler classes Express and Koa middleware, DOM event propagation with stopPropagation()

        The language feature you use most is a chain too. An exception unwinds the call stack. Each enclosing try either has a matching catch and handles it, or lets it pass up.

        Pattern Looks like Chain of responsibility because The difference
        Decorator A line of objects, each passing the call to the next A decorator always calls the object it wraps and adds behavior before or after. A handler may stop the request and never call the next one. Middleware frameworks allow both, so a timing layer is a decorator and an auth check that returns 401 is a handler.
        Composite Calls travel from object to object through links A composite sends a call to all its children and combines their results. A chain sends it to one next handler and stops at the first that takes it. They often meet: the logging framework passes a record up a logger tree, which is a chain along a composite’s parent links.
        Command Both are about passing requests around A command turns a request into an object so it can be queued, logged or undone. A chain decides who handles a request. A chain can pass command objects along.
        Observer An event reaches several objects Every observer hears every event, and none can stop the others. In a chain, handlers run in order and one can end it. DOM events show both: listeners on one element are observers, and bubbling up the tree is a chain.
        A list of rules in a loop It produces the same result for rule in rules: if decision := rule(x): return decision is often the simplest correct implementation, and it’s what the Medium and Hard pipelines are underneath. Reach for handler objects when handlers need their own dependencies, state or settings, or wrap the rest of the chain.
        Strategy Each handler is a small object behind an interface A strategy is one algorithm chosen up front. A chain tries several handlers in order until one takes the request.

        On this site:

        • Logging framework: a record is passed from a logger up to its parents. Each logger writes it to its own appenders. A non-additive logger stops the walk.
        • Decorator: the Medium level’s retry, timeout and logging layers are the before-and-after half of what middleware does. This page’s Medium level is the other half.

        In real systems, you’ll find chains in:

        • Exception handling, and DOM event bubbling.
        • Servlet and Spring Security filter chains.
        • ASP.NET Core, Express and Koa middleware.
        • Netty’s channel pipeline. Each handler passes an event on with ctx.fireChannelRead, or consumes it.
        • Kubernetes admission controllers. Each one can reject a request.
        • Support ticket escalation, and the rules engines behind fraud and content moderation.
        Interviewer asksHow is Chain of responsibility different from Decorator?

        Both link objects that pass a call along. A decorator always delegates to the object it wraps, and adds behavior around the call. A handler decides whether to deal with the request, and may never call the next one. A good test is “can this object end the request?” If yes, it’s a handler.

        Interviewer asksWhat happens if no handler handles the request?

        It’s a case the design must answer explicitly. Return an “unhandled” result with a reason. Fail with a clear error. Or end the chain with a handler that always decides (a default approver, a manual review queue, a 404). Returning null or a default “yes” hides configuration mistakes.

        Interviewer asksLinked handlers or a list and a loop?

        Linked handlers (each with next) are the classic form. They’re fine for a chain built once in code. A list owned by a pipeline keeps the order in one place. It lets handlers be built from configuration, and reused across chains. And it makes the chain easy to replace as a whole. When handlers are pure data, like limits per role, a list of rules and a loop is the simplest correct design.

        Interviewer asksHow do you test a chain?

        Test each handler alone with a fake next: does it answer when it should, and does it call next when it shouldn’t answer? Then test the assembled chain with one request per handler that should stop it, plus one that reaches the end. The order deserves its own test, because a wrong order still runs.

        Interviewer asksHow do you change a chain while it's serving traffic?

        Don’t edit it in place. Build a new, immutable chain, validate it, and publish it with one atomic reference swap. Each request reads the reference once and keeps that version, so it never sees a half-changed chain, and nothing is locked on the request path. Keep state such as counters outside the chain so a swap doesn’t reset it, and record the chain version in each decision.

        Decision We chose We gave up
        Who handles a request The first handler that takes it, found at run time Seeing every rule in one function
        End of the chain An explicit “unhandled” outcome, or a last handler that always decides Nothing; null is never the answer
        How handlers link A next field (Easy), a list owned by a pipeline (Medium, Hard) With next, the order is spread across objects
        Building the chain From names in config, checked at startup Compile-time checks on names and order
        Order Must-apply rules first, then by cost and hit rate Order is a correctness concern the compiler can’t check
        Slow or failing checks A time budget and fail open or closed per check Each policy has a cost: risk let through, or customers refused
        Audits Verdict, deciding check, reason, trail and chain version A record to store for every request
        Changing the chain live Build a new immutable chain, swap one reference Two versions run side by side for a while
        • Decorator, for layers that always delegate and the order of wrappers
        • Logging framework, where a record walks up a logger tree
        • Observer, for events every subscriber should hear
        • Command, for turning requests into objects a chain can pass along
        • Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns, the Chain of Responsibility chapter