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.
- Read first
- StrategyOpen/closed
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 shape
Section titled “The shape”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
nextreference. 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.
- 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 |
Linked handlers or a list
Section titled “Linked handlers or a list”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.
Worked examples
Section titled “Worked examples”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.
Without the pattern
Section titled “Without the pattern”One function holds every rule:
// 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 }}# 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.def approve(amount: int, category: str) -> str: if category == "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 f"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.def approve_subsidiary(amount: int, category: str) -> str: if amount <= 500: return "approved by TeamLead" if amount <= 250_000: return "approved by CFO" return f"unhandled: nobody can approve ${amount}"
print(approve(3_200, "travel")) # approved by Managerprint(approve(80, "gift cards")) # rejected: gift cards are not reimbursableprint(approve_subsidiary(80, "gift cards")) # approved by TeamLeadusing System;
// Every approval rule lives in one method. Who approves what is buried in the order of// the if statements, and the org chart is hard-coded into the code.public static class Approvals{ public static string Approve(int amount, string category) { if (category == "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 method. // Whoever wrote it forgot the gift card rule, and nothing tells them. public 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}"; }}
public static class Program{ public static void Main() { Console.WriteLine(Approvals.Approve(3_200, "travel")); // approved by Manager Console.WriteLine(Approvals.Approve(80, "gift cards")); // rejected: gift cards are not reimbursable Console.WriteLine(Approvals.ApproveSubsidiary(80, "gift cards")); // approved by TeamLead }}package main
import "fmt"
// approve holds every approval rule in one function. Who approves what is buried in the// order of the checks, and the org chart is hard-coded into the code.func approve(amount int, category string) string { if category == "gift cards" { return "rejected: gift cards are not reimbursable" } switch { case amount <= 500: return "approved by TeamLead" case amount <= 5_000: return "approved by Manager" case amount <= 50_000: return "approved by Director" case amount <= 250_000: return "approved by CFO" } return fmt.Sprintf("unhandled: nobody can approve $%d", amount)}
// approveSubsidiary exists because the subsidiary has no managers or directors, so it got// its own copy. Whoever wrote it forgot the gift card rule, and nothing tells them.func approveSubsidiary(amount int, category string) string { switch { case amount <= 500: return "approved by TeamLead" case amount <= 250_000: return "approved by CFO" } return fmt.Sprintf("unhandled: nobody can approve $%d", amount)}
func main() { fmt.Println(approve(3_200, "travel")) // approved by Manager fmt.Println(approve(80, "gift cards")) // rejected: gift cards are not reimbursable fmt.Println(approveSubsidiary(80, "gift cards")) // approved by TeamLead}// 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.function approve(amount: number, category: string): string { if (category === '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.function approveSubsidiary(amount: number, category: string): string { if (amount <= 500) return 'approved by TeamLead'; if (amount <= 250_000) return 'approved by CFO'; return `unhandled: nobody can approve $${amount}`;}
console.log(approve(3_200, 'travel')); // approved by Managerconsole.log(approve(80, 'gift cards')); // rejected: gift cards are not reimbursableconsole.log(approveSubsidiary(80, 'gift cards')); // approved by TeamLeadThe 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.
With a chain
Section titled “With a chain”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:
- 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
- PolicyCheckrejects gift cards
- TeamLeadapproves up to $500
- Managerapproves up to $5,000
- Directorapproves up to $50,000
- CFOapproves up to $250,000
end of chain
Pick an expense and send it.
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 }}from __future__ import annotations
from abc import ABC, abstractmethodfrom dataclasses import dataclass
@dataclass(frozen=True)class Expense: id: str amount: int category: str
def __str__(self) -> str: return f"{self.id} ${self.amount} {self.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 None.@dataclass(frozen=True)class Decision: verdict: str by: str | None reason: str
@staticmethod def unhandled(e: Expense) -> Decision: return Decision("unhandled", None, f"nobody can approve ${e.amount}")
def __str__(self) -> str: if self.by is None: return f"{self.verdict}: {self.reason}" return f"{self.verdict} by {self.by}" + (f": {self.reason}" if self.reason else "")
# 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.class Approver(ABC): def __init__(self) -> None: self._next: Approver | None = None
# Returns the handler it was given, so a chain reads left to right: # policy.then(lead).then(manager)... def then(self, nxt: Approver) -> Approver: self._next = nxt return nxt
# The passing logic lives here, once. Subclasses only answer "is this mine?", so no # handler can forget to pass the request on. def handle(self, e: Expense) -> Decision: d = self.decide(e) if d is not None: return d # this handler took it: stop here if self._next is not None: return self._next.handle(e) # not mine: pass it on return Decision.unhandled(e) # end of the chain, and nobody took it
# None means "not mine". @abstractmethod def decide(self, e: Expense) -> Decision | None: ...
# A handler can take a request by refusing it. It still stops the chain.class PolicyCheck(Approver): def __init__(self, banned: set[str]): super().__init__() self.banned = banned
def decide(self, e: Expense) -> Decision | None: if e.category not in self.banned: return None return Decision("rejected", "PolicyCheck", f"{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(Approver): def __init__(self, role: str, limit: int): super().__init__() self.role, self.limit = role, limit
def decide(self, e: Expense) -> Decision | None: return Decision("approved", self.role, "") if e.amount <= self.limit else None
def policy() -> Approver: return PolicyCheck({"gift cards"})
# The org chart is data, wired once at startup. The sender holds only the head.head = policy()( head.then(LimitApprover("TeamLead", 500)) .then(LimitApprover("Manager", 5_000)) .then(LimitApprover("Director", 50_000)) .then(LimitApprover("CFO", 250_000)))
travel = Expense("E-2", 3_200, "travel")gift = Expense("E-3", 80, "gift cards")for e in [Expense("E-1", 120, "software"), travel, gift, Expense("E-4", 400_000, "equipment")]: print(f"{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.sub = policy()sub.then(LimitApprover("TeamLead", 500)).then(LimitApprover("CFO", 250_000))print(f"{travel} -> {sub.handle(travel)}") # E-2 $3200 travel -> approved by CFOprint(f"{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.wrong = LimitApprover("TeamLead", 500)wrong.then(policy()).then(LimitApprover("CFO", 250_000))print(f"{gift} -> {wrong.handle(gift)}") # E-3 $80 gift cards -> approved by TeamLeadusing System;using System.Collections.Generic;
public sealed record Expense(string Id, int Amount, string Category){ public override string ToString() => $"{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.public sealed record Decision(string Verdict, string? By, string Reason){ public static Decision Unhandled(Expense e) => new("unhandled", null, $"nobody can approve ${e.Amount}");
public override string ToString() => By is null ? $"{Verdict}: {Reason}" : $"{Verdict} by {By}" + (Reason == "" ? "" : $": {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.public abstract class Approver{ private Approver? _next;
// Returns the handler it was given, so a chain reads left to right: // policy.Then(lead).Then(manager)... public Approver Then(Approver next) { _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. public Decision Handle(Expense e) { var d = Decide(e); if (d is not null) return d; // this handler took it: stop here if (_next is not 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.public sealed class PolicyCheck : Approver{ private readonly HashSet<string> _banned; public PolicyCheck(params string[] banned) => _banned = new HashSet<string>(banned);
protected override Decision? Decide(Expense e) => _banned.Contains(e.Category) ? new Decision("rejected", "PolicyCheck", $"{e.Category} are not reimbursable") : null;}
// 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.public sealed class LimitApprover : Approver{ private readonly string _role; private readonly int _limit; public LimitApprover(string role, int limit) { _role = role; _limit = limit; }
protected override Decision? Decide(Expense e) => e.Amount <= _limit ? new Decision("approved", _role, "") : null;}
public static class Program{ static Approver Policy() => new PolicyCheck("gift cards");
public static void Main() { // The org chart is data, wired once at startup. The sender holds only the head. var 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 = new[] { new Expense("E-1", 120, "software"), travel, gift, new Expense("E-4", 400_000, "equipment") }; foreach (var e in expenses) Console.WriteLine($"{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. var sub = Policy(); sub.Then(new LimitApprover("TeamLead", 500)).Then(new LimitApprover("CFO", 250_000)); Console.WriteLine($"{travel} -> {sub.Handle(travel)}"); // E-2 $3200 travel -> approved by CFO Console.WriteLine($"{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. var wrong = new LimitApprover("TeamLead", 500); wrong.Then(Policy()).Then(new LimitApprover("CFO", 250_000)); Console.WriteLine($"{gift} -> {wrong.Handle(gift)}"); // E-3 $80 gift cards -> approved by TeamLead }}package main
import "fmt"
type Expense struct { ID string Amount int Category string}
func (e Expense) String() string { return fmt.Sprintf("%s $%d %s", e.ID, e.Amount, e.Category) }
// Decision is 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.type Decision struct { Verdict, By, Reason string}
func unhandled(e Expense) Decision { return Decision{Verdict: "unhandled", Reason: fmt.Sprintf("nobody can approve $%d", e.Amount)}}
func (d Decision) String() string { if d.By == "" { return d.Verdict + ": " + d.Reason } if d.Reason == "" { return d.Verdict + " by " + d.By } return d.Verdict + " by " + d.By + ": " + d.Reason}
// Approver is one link: a rule and the next link. The rule returns nil for "not mine".// Go has no inheritance, so the varying part is a function and the passing logic is a// method on the one struct.type Approver struct { decide func(Expense) *Decision next *Approver}
// Then returns the approver it was given, so a chain reads left to right.func (a *Approver) Then(next *Approver) *Approver { a.next = next return next}
// Handle holds the passing logic, once. Rules only answer "is this mine?", so no rule can// forget to pass the request on.func (a *Approver) Handle(e Expense) Decision { if d := a.decide(e); d != nil { return *d // this handler took it: stop here } if a.next != nil { return a.next.Handle(e) // not mine: pass it on } return unhandled(e) // end of the chain, and nobody took it}
// PolicyCheck takes a request by refusing it. That still stops the chain.func PolicyCheck(banned ...string) *Approver { set := map[string]bool{} for _, b := range banned { set[b] = true } return &Approver{decide: func(e Expense) *Decision { if !set[e.Category] { return nil } return &Decision{"rejected", "PolicyCheck", e.Category + " are not reimbursable"} }}}
// LimitApprover serves four roles because they differ only in data. A role with its own// logic (a travel desk that checks the destination, say) would get its own constructor.func LimitApprover(role string, limit int) *Approver { return &Approver{decide: func(e Expense) *Decision { if e.Amount > limit { return nil } return &Decision{Verdict: "approved", By: role} }}}
func policy() *Approver { return PolicyCheck("gift cards") }
func main() { // The org chart is data, wired once at startup. The sender holds only the head. head := policy() head.Then(LimitApprover("TeamLead", 500)). Then(LimitApprover("Manager", 5_000)). Then(LimitApprover("Director", 50_000)). Then(LimitApprover("CFO", 250_000))
travel := Expense{"E-2", 3_200, "travel"} gift := Expense{"E-3", 80, "gift cards"} for _, e := range []Expense{{"E-1", 120, "software"}, travel, gift, {"E-4", 400_000, "equipment"}} { fmt.Printf("%v -> %v\n", 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 rules, a shorter chain, and the // policy check comes along because it's a link, not a copied if statement. sub := policy() sub.Then(LimitApprover("TeamLead", 500)).Then(LimitApprover("CFO", 250_000)) fmt.Printf("%v -> %v\n", travel, sub.Handle(travel)) // E-2 $3200 travel -> approved by CFO fmt.Printf("%v -> %v\n", 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. wrong := LimitApprover("TeamLead", 500) wrong.Then(policy()).Then(LimitApprover("CFO", 250_000)) fmt.Printf("%v -> %v\n", gift, wrong.Handle(gift)) // E-3 $80 gift cards -> approved by TeamLead}class Expense { constructor( readonly id: string, readonly amount: number, readonly category: string, ) {} toString(): string { return `${this.id} $${this.amount} ${this.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.class Decision { constructor( readonly verdict: string, readonly by: string | null, readonly reason: string, ) {} static unhandled(e: Expense): Decision { return new Decision('unhandled', null, `nobody can approve $${e.amount}`); } toString(): string { if (this.by === null) return `${this.verdict}: ${this.reason}`; return `${this.verdict} by ${this.by}` + (this.reason ? `: ${this.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 next: Approver | null = null;
// Returns the handler it was given, so a chain reads left to right: // policy.then(lead).then(manager)... then(next: Approver): Approver { 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. handle(e: Expense): Decision { const d = this.decide(e); if (d !== null) return d; // this handler took it: stop here if (this.next !== null) return this.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 decide(e: Expense): Decision | null;}
// A handler can take a request by refusing it. It still stops the chain.class PolicyCheck extends Approver { constructor(private readonly banned: Set<string>) { super(); } protected decide(e: Expense): Decision | null { if (!this.banned.has(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 { constructor( private readonly role: string, private readonly limit: number, ) { super(); } protected decide(e: Expense): Decision | null { return e.amount <= this.limit ? new Decision('approved', this.role, '') : null; }}
const policy = (): Approver => new PolicyCheck(new Set(['gift cards']));
// The org chart is data, wired once at startup. The sender holds only the head.const 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));
const travel = new Expense('E-2', 3_200, 'travel');const gift = new Expense('E-3', 80, 'gift cards');for (const e of [new Expense('E-1', 120, 'software'), travel, gift, new Expense('E-4', 400_000, 'equipment')]) { console.log(`${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.const sub = policy();sub.then(new LimitApprover('TeamLead', 500)).then(new LimitApprover('CFO', 250_000));console.log(`${travel} -> ${sub.handle(travel)}`); // E-2 $3200 travel -> approved by CFOconsole.log(`${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.const wrong = new LimitApprover('TeamLead', 500);wrong.then(policy()).then(new LimitApprover('CFO', 250_000));console.log(`${gift} -> ${wrong.handle(gift)}`); // E-3 $80 gift cards -> approved by TeamLeadWhat to notice
Section titled “What to notice”- The passing logic is written once.
handlein the base class asksdecide. It stops if there’s an answer, and otherwise callsnext. 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, andHandleis a method on the oneApproverstruct. 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.
PolicyChecktakes 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
handlereturns anunhandleddecision that says why. Returningnull, or approving by default, would let a $400,000 expense through quietly or crash somewhere far from the cause. - Order matters. With
PolicyCheckafterTeamLead, 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.
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.
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.
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.
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.
Without the pattern
Section titled “Without the pattern”Each endpoint does its own checks, inline:
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 }}# Every endpoint does its own checks, inline, before its real work. The checks and their# order are copied from endpoint to endpoint.class OrdersApi: def __init__(self) -> None: self.tokens = {"t-alice": "alice", "t-bob": "bob"} self.counts: dict[str, int] = {}
def create_order(self, token: str | None, qty: int) -> str: if token is None: return "401 missing token" user = self.tokens.get(token) if user is None: return "401 invalid token" self.counts[user] = self.counts.get(user, 0) + 1 if self.counts[user] > 2: return "429 slow down" if qty < 1 or qty > 99: return "400 qty must be 1..99" return f"201 order for {user}"
# Copied from create_order, minus the rate limit, which nobody noticed was missing. # Adding a check (say, blocking suspended accounts) means editing every endpoint. def cancel_order(self, token: str | None, order_id: str) -> str: if token is None: return "401 missing token" user = self.tokens.get(token) if user is None: return "401 invalid token" return f"200 cancelled {order_id}"
api = OrdersApi()print(api.create_order(None, 1)) # 401 missing tokenprint(api.create_order("t-alice", 0)) # 400 qty must be 1..99for i in range(3): print(api.cancel_order("t-alice", f"O-{i}"))# 200 cancelled O-0# 200 cancelled O-1# 200 cancelled O-2using System;using System.Collections.Generic;
// Every endpoint does its own checks, inline, before its real work. The checks and their// order are copied from endpoint to endpoint.public sealed class OrdersApi{ private readonly Dictionary<string, string> _tokens = new() { ["t-alice"] = "alice", ["t-bob"] = "bob" }; private readonly Dictionary<string, int> _counts = new();
public string CreateOrder(string? token, int qty) { if (token is null) return "401 missing token"; if (!_tokens.TryGetValue(token, out var user)) return "401 invalid token"; _counts[user] = _counts.GetValueOrDefault(user) + 1; if (_counts[user] > 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. public string CancelOrder(string? token, string orderId) { if (token is null) return "401 missing token"; if (!_tokens.TryGetValue(token, out _)) return "401 invalid token"; return $"200 cancelled {orderId}"; }}
public static class Program{ public static void Main() { var api = new OrdersApi(); Console.WriteLine(api.CreateOrder(null, 1)); // 401 missing token Console.WriteLine(api.CreateOrder("t-alice", 0)); // 400 qty must be 1..99 for (int i = 0; i < 3; i++) Console.WriteLine(api.CancelOrder("t-alice", $"O-{i}")); // 200 cancelled O-0 // 200 cancelled O-1 // 200 cancelled O-2 }}package main
import "fmt"
// OrdersAPI does its checks inline in every endpoint, before the real work. The checks and// their order are copied from endpoint to endpoint.type OrdersAPI struct { tokens map[string]string counts map[string]int}
func (a *OrdersAPI) CreateOrder(token string, qty int) string { if token == "" { return "401 missing token" } user, ok := a.tokens[token] if !ok { return "401 invalid token" } a.counts[user]++ if a.counts[user] > 2 { return "429 slow down" } if qty < 1 || qty > 99 { return "400 qty must be 1..99" } return "201 order for " + user}
// CancelOrder was copied from CreateOrder, minus the rate limit, which nobody noticed was// missing. Adding a check (say, blocking suspended accounts) means editing every endpoint.func (a *OrdersAPI) CancelOrder(token, orderID string) string { if token == "" { return "401 missing token" } if _, ok := a.tokens[token]; !ok { return "401 invalid token" } return "200 cancelled " + orderID}
func main() { api := &OrdersAPI{tokens: map[string]string{"t-alice": "alice", "t-bob": "bob"}, counts: map[string]int{}} fmt.Println(api.CreateOrder("", 1)) // 401 missing token fmt.Println(api.CreateOrder("t-alice", 0)) // 400 qty must be 1..99 for i := 0; i < 3; i++ { fmt.Println(api.CancelOrder("t-alice", fmt.Sprintf("O-%d", i))) } // 200 cancelled O-0 // 200 cancelled O-1 // 200 cancelled O-2}// Every endpoint does its own checks, inline, before its real work. The checks and their// order are copied from endpoint to endpoint.class OrdersApi { private readonly tokens = new Map([ ['t-alice', 'alice'], ['t-bob', 'bob'], ]); private readonly counts = new Map<string, number>();
createOrder(token: string | null, qty: number): string { if (token === null) return '401 missing token'; const user = this.tokens.get(token); if (user === undefined) return '401 invalid token'; const n = (this.counts.get(user) ?? 0) + 1; this.counts.set(user, n); if (n > 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. cancelOrder(token: string | null, orderId: string): string { if (token === null) return '401 missing token'; if (!this.tokens.has(token)) return '401 invalid token'; return `200 cancelled ${orderId}`; }}
const api = new OrdersApi();console.log(api.createOrder(null, 1)); // 401 missing tokenconsole.log(api.createOrder('t-alice', 0)); // 400 qty must be 1..99for (let i = 0; i < 3; i++) console.log(api.cancelOrder('t-alice', `O-${i}`));// 200 cancelled O-0// 200 cancelled O-1// 200 cancelled O-2cancelOrder 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.
With a pipeline
Section titled “With a pipeline”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:
- 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
- auth401 without a valid token; sets user
- rateLimit429 after 2 requests per key
- validate400 unless qty is 1..99
- 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
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 }}from __future__ import annotations
from dataclasses import dataclassfrom typing import Callable, Protocol
# 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.@dataclassclass Request: label: str ip: str token: str | None qty: int user: str | None = None
# Who answered is part of the response, so logs and tests can say which handler stopped it.@dataclass(frozen=True)class Response: status: int by: str message: str
def __str__(self) -> str: return f"{self.status} by {self.by} ({self.message})"
Next = Callable[[Request], Response]
# 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.class Handler(Protocol): def handle(self, req: Request, nxt: Next) -> Response: ...
class Auth: def __init__(self, tokens: dict[str, str]): self.tokens = tokens # token -> user
def handle(self, req: Request, nxt: Next) -> Response: if req.token is None: return Response(401, "auth", "missing token") user = self.tokens.get(req.token) if user is None: return Response(401, "auth", "invalid token") req.user = user # handlers after this one can trust it return nxt(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: def __init__(self, limit: int): self.limit = limit self.counts: dict[str, int] = {}
def handle(self, req: Request, nxt: Next) -> Response: key = f"user:{req.user}" if req.user is not None else f"ip:{req.ip}" self.counts[key] = self.counts.get(key, 0) + 1 if self.counts[key] > self.limit: return Response(429, "rateLimit", f"over {self.limit} for {key}") return nxt(req)
class Validate: def handle(self, req: Request, nxt: Next) -> Response: if req.qty < 1 or req.qty > 99: return Response(400, "validate", "qty must be 1..99") return nxt(req)
# The endpoint is just the last handler: it never calls next.class CreateOrder: def handle(self, req: Request, nxt: Next) -> Response: return Response(201, "createOrder", f"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.class Pipeline: def __init__(self, handlers: list[Handler]): self._handlers = tuple(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. @staticmethod def from_config(names: list[str], registry: dict[str, Callable[[], Handler]], endpoint: Handler) -> Pipeline: handlers: list[Handler] = [] for name in names: make = registry.get(name) if make is None: known = ", ".join(sorted(registry)) raise ValueError(f"unknown handler '{name}' (known: {known})") handlers.append(make()) handlers.append(endpoint) return Pipeline(handlers)
def send(self, req: Request) -> Response: return self._at(0, req)
def _at(self, i: int, req: Request) -> Response: # Falling off the end means the chain has no endpoint. Say so instead of returning None. if i == len(self._handlers): return Response(500, "pipeline", "no handler answered") return self._handlers[i].handle(req, lambda r: self._at(i + 1, r))
OFFICE = "10.0.0.7" # alice and bob share the office's public IP
def alice(qty: int) -> Request: return Request("alice: qty 0" if qty == 0 else "alice: valid", OFFICE, "t-alice", qty)
def bob() -> Request: return Request("bob: valid", OFFICE, "t-bob", 2)
def anon() -> Request: return Request("anon: no token", "203.0.113.5", None, 2)
def run(p: Pipeline, reqs: list[Request]) -> None: for r in reqs: print(f"{r.label:<15} -> {p.send(r)}")
tokens = {"t-alice": "alice", "t-bob": "bob"}registry: dict[str, Callable[[], Handler]] = { "auth": lambda: Auth(tokens), "rateLimit": lambda: RateLimit(2), "validate": Validate,}
print("auth -> rateLimit -> validate -> createOrder")good = Pipeline.from_config(["auth", "rateLimit", "validate"], registry, CreateOrder())run(good, [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.print("rateLimit -> auth -> validate -> createOrder")swapped = Pipeline.from_config(["rateLimit", "auth", "validate"], registry, CreateOrder())run(swapped, [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.from_config(["auth", "cors"], registry, CreateOrder())except ValueError as e: print(e) # unknown handler 'cors' (known: auth, rateLimit, validate)
# A handler is testable alone: pass a fake next and check whether it was called.called = False
def fake_next(req: Request) -> Response: global called called = True return Response(200, "fake", "")
r = Auth(tokens).handle(anon(), fake_next)print(f"auth alone: {r.status}, next called: {str(called).lower()}") # auth alone: 401, next called: falseusing System;using System.Collections.Generic;using System.Linq;
// 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.public sealed class Request{ public string Label { get; } public string Ip { get; } public string? Token { get; } public int Qty { get; } public string? User { get; set; }
public Request(string label, string ip, string? token, int qty) { Label = label; Ip = ip; Token = token; Qty = qty; }}
// Who answered is part of the response, so logs and tests can say which handler stopped it.public sealed record Response(int Status, string By, string Message){ public override string ToString() => $"{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.public interface IHandler{ Response Handle(Request req, Func<Request, Response> next);}
public sealed class Auth : IHandler{ private readonly IReadOnlyDictionary<string, string> _tokens; // token -> user public Auth(IReadOnlyDictionary<string, string> tokens) => _tokens = tokens;
public Response Handle(Request req, Func<Request, Response> next) { if (req.Token is null) return new Response(401, "auth", "missing token"); if (!_tokens.TryGetValue(req.Token, out var user)) return new Response(401, "auth", "invalid token"); req.User = user; // handlers after this one can trust it return next(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.public sealed class RateLimit : IHandler{ private readonly int _limit; private readonly Dictionary<string, int> _counts = new(); public RateLimit(int limit) => _limit = limit;
public Response Handle(Request req, Func<Request, Response> next) { var key = req.User is not null ? $"user:{req.User}" : $"ip:{req.Ip}"; _counts[key] = _counts.GetValueOrDefault(key) + 1; if (_counts[key] > _limit) return new Response(429, "rateLimit", $"over {_limit} for {key}"); return next(req); }}
public sealed class Validate : IHandler{ public Response Handle(Request req, Func<Request, Response> next) => req.Qty < 1 || req.Qty > 99 ? new Response(400, "validate", "qty must be 1..99") : next(req);}
// The endpoint is just the last handler: it never calls next.public sealed class CreateOrder : IHandler{ public Response Handle(Request req, Func<Request, Response> next) => new(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.public sealed class Pipeline{ private readonly IReadOnlyList<IHandler> _handlers; private Pipeline(List<IHandler> handlers) => _handlers = handlers.AsReadOnly();
// 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. public static Pipeline FromConfig(IEnumerable<string> names, IReadOnlyDictionary<string, Func<IHandler>> registry, IHandler endpoint) { var handlers = new List<IHandler>(); foreach (var name in names) { if (!registry.TryGetValue(name, out var make)) throw new ArgumentException( $"unknown handler '{name}' (known: {string.Join(", ", registry.Keys.OrderBy(k => k, StringComparer.Ordinal))})"); handlers.Add(make()); } handlers.Add(endpoint); return new Pipeline(handlers); }
public Response Send(Request req) => 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.Count) return new Response(500, "pipeline", "no handler answered"); return _handlers[i].Handle(req, r => At(i + 1, r)); }}
public static class Program{ const string Office = "10.0.0.7"; // alice and bob share the office's public IP
static Request Alice(int qty) => new(qty == 0 ? "alice: qty 0" : "alice: valid", Office, "t-alice", qty); static Request Bob() => new("bob: valid", Office, "t-bob", 2); static Request Anon() => new("anon: no token", "203.0.113.5", null, 2);
static void Run(Pipeline p, params Request[] reqs) { foreach (var r in reqs) Console.WriteLine($"{r.Label,-15} -> {p.Send(r)}"); }
public static void Main() { var tokens = new Dictionary<string, string> { ["t-alice"] = "alice", ["t-bob"] = "bob" }; var registry = new Dictionary<string, Func<IHandler>> { ["auth"] = () => new Auth(tokens), ["rateLimit"] = () => new RateLimit(2), ["validate"] = () => new Validate(), };
Console.WriteLine("auth -> rateLimit -> validate -> createOrder"); var good = Pipeline.FromConfig(new[] { "auth", "rateLimit", "validate" }, registry, new CreateOrder()); Run(good, 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. Console.WriteLine("rateLimit -> auth -> validate -> createOrder"); var swapped = Pipeline.FromConfig(new[] { "rateLimit", "auth", "validate" }, registry, new CreateOrder()); Run(swapped, 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(new[] { "auth", "cors" }, registry, new CreateOrder()); } catch (ArgumentException e) { Console.WriteLine(e.Message); // unknown handler 'cors' (known: auth, rateLimit, validate) }
// A handler is testable alone: pass a fake next and check whether it was called. var called = false; var res = new Auth(tokens).Handle(Anon(), _ => { called = true; return new Response(200, "fake", ""); }); Console.WriteLine($"auth alone: {res.Status}, next called: {called.ToString().ToLowerInvariant()}"); // auth alone: 401, next called: false }}package main
import ( "fmt" "sort" "strings")
// Request is 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.type Request struct { Label, IP, Token string Qty int User string}
// Response says who answered, so logs and tests can say which handler stopped it.type Response struct { Status int By, Message string}
func (r Response) String() string { return fmt.Sprintf("%d by %s (%s)", r.Status, r.By, r.Message) }
// Next is the rest of the chain.type Next func(*Request) Response
// 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 handler works// in any pipeline, at any position.type Handler interface { Handle(req *Request, next Next) Response}
type Auth struct{ tokens map[string]string } // token -> user
func (a *Auth) Handle(req *Request, next Next) Response { if req.Token == "" { return Response{401, "auth", "missing token"} } user, ok := a.tokens[req.Token] if !ok { return Response{401, "auth", "invalid token"} } req.User = user // handlers after this one can trust it return next(req)}
// RateLimit keys by user when auth has already run, otherwise by IP. Which key it gets// depends only on where it sits in the chain.type RateLimit struct { limit int counts map[string]int}
func (r *RateLimit) Handle(req *Request, next Next) Response { key := "ip:" + req.IP if req.User != "" { key = "user:" + req.User } r.counts[key]++ if r.counts[key] > r.limit { return Response{429, "rateLimit", fmt.Sprintf("over %d for %s", r.limit, key)} } return next(req)}
type Validate struct{}
func (Validate) Handle(req *Request, next Next) Response { if req.Qty < 1 || req.Qty > 99 { return Response{400, "validate", "qty must be 1..99"} } return next(req)}
// CreateOrder is the endpoint, which is just the last handler: it never calls next.type CreateOrder struct{}
func (CreateOrder) Handle(req *Request, next Next) Response { return Response{201, "createOrder", "order for " + req.User}}
// Pipeline holds the handlers in order and hands each one "the rest of the chain". The// order lives here, in one slice built from config, not in fields scattered across handlers.type Pipeline struct{ handlers []Handler }
// FromConfig fails on unknown names 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.func FromConfig(names []string, registry map[string]func() Handler, endpoint Handler) (*Pipeline, error) { var hs []Handler for _, name := range names { build, ok := registry[name] if !ok { return nil, fmt.Errorf("unknown handler '%s' (known: %s)", name, knownNames(registry)) } hs = append(hs, build()) } return &Pipeline{append(hs, endpoint)}, nil}
func knownNames(registry map[string]func() Handler) string { keys := make([]string, 0, len(registry)) for k := range registry { keys = append(keys, k) } sort.Strings(keys) return strings.Join(keys, ", ")}
func (p *Pipeline) Send(req *Request) Response { return p.at(0, req) }
func (p *Pipeline) at(i int, req *Request) Response { // Falling off the end means the chain has no endpoint. Say so instead of a zero value. if i == len(p.handlers) { return Response{500, "pipeline", "no handler answered"} } return p.handlers[i].Handle(req, func(r *Request) Response { return p.at(i+1, r) })}
const office = "10.0.0.7" // alice and bob share the office's public IP
func alice(qty int) *Request { label := "alice: valid" if qty == 0 { label = "alice: qty 0" } return &Request{Label: label, IP: office, Token: "t-alice", Qty: qty}}func bob() *Request { return &Request{Label: "bob: valid", IP: office, Token: "t-bob", Qty: 2} }func anon() *Request { return &Request{Label: "anon: no token", IP: "203.0.113.5", Qty: 2} }
func run(p *Pipeline, reqs ...*Request) { for _, r := range reqs { fmt.Printf("%-15s -> %v\n", r.Label, p.Send(r)) }}
func main() { tokens := map[string]string{"t-alice": "alice", "t-bob": "bob"} registry := map[string]func() Handler{ "auth": func() Handler { return &Auth{tokens} }, "rateLimit": func() Handler { return &RateLimit{2, map[string]int{}} }, "validate": func() Handler { return Validate{} }, }
fmt.Println("auth -> rateLimit -> validate -> createOrder") good, _ := FromConfig([]string{"auth", "rateLimit", "validate"}, registry, CreateOrder{}) run(good, 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. fmt.Println("rateLimit -> auth -> validate -> createOrder") swapped, _ := FromConfig([]string{"rateLimit", "auth", "validate"}, registry, CreateOrder{}) run(swapped, 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)
if _, err := FromConfig([]string{"auth", "cors"}, registry, CreateOrder{}); err != nil { fmt.Println(err) // unknown handler 'cors' (known: auth, rateLimit, validate) }
// A handler is testable alone: pass a fake next and check whether it was called. called := false res := (&Auth{tokens}).Handle(anon(), func(*Request) Response { called = true; return Response{} }) fmt.Printf("auth alone: %d, next called: %v\n", res.Status, called) // auth alone: 401, next called: false}// 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.class Request { user: string | null = null; constructor( readonly label: string, readonly ip: string, readonly token: string | null, readonly qty: number, ) {}}
// Who answered is part of the response, so logs and tests can say which handler stopped it.class Response { constructor( readonly status: number, readonly by: string, readonly message: string, ) {} toString(): string { return `${this.status} by ${this.by} (${this.message})`; }}
type Next = (req: Request) => Response;
// 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 { handle(req: Request, next: Next): Response;}
class Auth implements Handler { constructor(private readonly tokens: Map<string, string>) {} // token -> user
handle(req: Request, next: Next): Response { if (req.token === null) return new Response(401, 'auth', 'missing token'); const user = this.tokens.get(req.token); if (user === undefined) return new Response(401, 'auth', 'invalid token'); req.user = user; // handlers after this one can trust it return next(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 readonly counts = new Map<string, number>(); constructor(private readonly limit: number) {}
handle(req: Request, next: Next): Response { const key = req.user !== null ? `user:${req.user}` : `ip:${req.ip}`; const n = (this.counts.get(key) ?? 0) + 1; this.counts.set(key, n); if (n > this.limit) return new Response(429, 'rateLimit', `over ${this.limit} for ${key}`); return next(req); }}
class Validate implements Handler { handle(req: Request, next: Next): Response { if (req.qty < 1 || req.qty > 99) return new Response(400, 'validate', 'qty must be 1..99'); return next(req); }}
// The endpoint is just the last handler: it never calls next.class CreateOrder implements Handler { handle(req: Request, _next: Next): Response { 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.class Pipeline { private constructor(private readonly handlers: readonly Handler[]) {}
// 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 fromConfig(names: string[], registry: Map<string, () => Handler>, endpoint: Handler): Pipeline { const handlers = names.map((name) => { const make = registry.get(name); if (make === undefined) { const known = [...registry.keys()].sort().join(', '); throw new Error(`unknown handler '${name}' (known: ${known})`); } return make(); }); return new Pipeline([...handlers, endpoint]); }
send(req: Request): Response { return this.at(0, req); }
private at(i: number, req: Request): Response { // Falling off the end means the chain has no endpoint. Say so instead of returning null. if (i === this.handlers.length) return new Response(500, 'pipeline', 'no handler answered'); return this.handlers[i].handle(req, (r) => this.at(i + 1, r)); }}
const OFFICE = '10.0.0.7'; // alice and bob share the office's public IPconst alice = (qty: number) => new Request(qty === 0 ? 'alice: qty 0' : 'alice: valid', OFFICE, 't-alice', qty);const bob = () => new Request('bob: valid', OFFICE, 't-bob', 2);const anon = () => new Request('anon: no token', '203.0.113.5', null, 2);
function run(p: Pipeline, reqs: Request[]): void { for (const r of reqs) console.log(`${r.label.padEnd(15)} -> ${p.send(r)}`);}
const tokens = new Map([ ['t-alice', 'alice'], ['t-bob', 'bob'],]);const registry = new Map<string, () => Handler>([ ['auth', () => new Auth(tokens)], ['rateLimit', () => new RateLimit(2)], ['validate', () => new Validate()],]);
console.log('auth -> rateLimit -> validate -> createOrder');const good = Pipeline.fromConfig(['auth', 'rateLimit', 'validate'], registry, new CreateOrder());run(good, [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.console.log('rateLimit -> auth -> validate -> createOrder');const swapped = Pipeline.fromConfig(['rateLimit', 'auth', 'validate'], registry, new CreateOrder());run(swapped, [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(['auth', 'cors'], registry, new CreateOrder());} catch (e) { console.log((e as Error).message); // unknown handler 'cors' (known: auth, rateLimit, validate)}
// A handler is testable alone: pass a fake next and check whether it was called.let called = false;const res = new Auth(tokens).handle(anon(), () => { called = true; return new Response(200, 'fake', '');});console.log(`auth alone: ${res.status}, next called: ${called}`); // auth alone: 401, next called: falseWhat to notice
Section titled “What to notice”- 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.
authchecks the token and records who the user is.rateLimitcounts.validatechecks the body. Any of them can answer instead, and the request goes no further. - Handlers don’t know their neighbours.
nextis passed in, not stored, so the order lives in one list. The sameAuthclass serves every endpoint, andcancelOrdercan’t lose its rate limit because it no longer has its own copy of the checks. - Built from configuration, checked at startup.
fromConfigturns 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.
rateLimitreadsuser, whichauthsets. 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. Putvalidatebeforeauthand 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 thannull. - One handler can be tested alone. Call
Auth.handlewith a fakenextthat 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 afternextreturns: 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.
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?
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.
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 chain under load
Section titled “The chain under load”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
- blocklistdeclines listed cards · 0 ms
- velocitydeclines from the 4th try per card · 1 ms
- modeldeclines ≥ 0.80, approves < 0.30 · 50 ms budget
- reviewholds $500 or more for a person · approves the rest
Model service
When the model times out
Per check
| Check | Reached | Decided | Avg 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.
With a chain built for production
Section titled “With a chain built for production”- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- Color key
- Entity
- Facade
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] }}from __future__ import annotations
import threadingfrom collections import dequefrom concurrent.futures import ThreadPoolExecutorfrom dataclasses import dataclassfrom enum import Enumfrom typing import Protocol
class Verdict(Enum): APPROVE = "APPROVE" DECLINE = "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.@dataclass(frozen=True)class Payment: id: str card: str amount: int score: float model_ms: int
# What a check says. A check returns None for "no opinion: ask the next one".@dataclass(frozen=True)class Opinion: verdict: Verdict reason: str
# A check that can't answer: a timeout, or a dependency that is down.class CheckFailed(Exception): pass
# The time one check may spend. Real code hands the deadline to the client it calls (a# timeout on the HTTP call, future.result(timeout=...)); this one counts fake milliseconds.class Budget: def __init__(self, limit_ms: int): self.limit_ms = limit_ms self.used_ms = 0
def spend(self, ms: int) -> None: if self.used_ms + ms > self.limit_ms: self.used_ms = self.limit_ms # we waited the whole budget, then gave up raise CheckFailed(f"timeout after {self.limit_ms} ms") self.used_ms += 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.class Check(Protocol): name: str
def check(self, p: Payment, budget: Budget) -> Opinion | None: ...
class Blocklist: name = "blocklist"
def __init__(self, cards: set[str]): self.cards = frozenset(cards)
def check(self, p: Payment, budget: Budget) -> Opinion | None: return Opinion(Verdict.DECLINE, "card on blocklist") if p.card in self.cards else None
# 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 happens under a lock.class AttemptCounter: def __init__(self) -> None: self._lock = threading.Lock() self._attempts: dict[str, int] = {}
def record(self, card: str) -> int: with self._lock: n = self._attempts.get(card, 0) + 1 self._attempts[card] = n return n
class Velocity: name = "velocity"
def __init__(self, counter: AttemptCounter, max_attempts: int): self.counter, self.max = counter, max_attempts
def check(self, p: Payment, budget: Budget) -> Opinion | None: budget.spend(1) # one round trip to the shared counter store n = self.counter.record(p.card) return Opinion(Verdict.DECLINE, f"{n} attempts on this card") if n > self.max else None
# Decides only when it is confident. The middle band is "no opinion".class RiskModel: name = "model"
def __init__(self, decline_at: float, approve_below: float): self.decline_at, self.approve_below = decline_at, approve_below
def check(self, p: Payment, budget: Budget) -> Opinion | None: budget.spend(p.model_ms) # the remote call; raises if it takes longer than the budget why = f"score {p.score:.2f}" if p.score >= self.decline_at: return Opinion(Verdict.DECLINE, why) if p.score < self.approve_below: return Opinion(Verdict.APPROVE, why) return None
# 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: name = "review"
def __init__(self, review_from: int, queue: deque[str]): self.review_from, self.queue = review_from, queue
def check(self, p: Payment, budget: Budget) -> Opinion | None: if p.amount < self.review_from: return Opinion(Verdict.APPROVE, "small amount, no clear signal") budget.spend(2) # insert into the review queue self.queue.append(p.id) # deque.append is thread-safe return Opinion(Verdict.HOLD, f"${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.class OnFailure(Enum): OPEN = "open" CLOSED = "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.@dataclass(frozen=True)class Link: check: Check timeout_ms: int on_failure: OnFailure
@dataclass(frozen=True)class Step: check: str outcome: str ms: int
def __str__(self) -> str: return f"{self.check} {self.outcome} {self.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.@dataclass(frozen=True)class Decision: id: str verdict: Verdict by: str reason: str version: int trail: tuple[Step, ...]
def __str__(self) -> str: return f"{self.id} {self.verdict.value} by {self.by}: {self.reason} (v{self.version})"
def trail_line(self) -> str: return " " + " > ".join(str(s) for s in self.trail)
# 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.class Chain: def __init__(self, version: int, links: list[Link]): self.version = version self._links = tuple(links)
def decide(self, p: Payment) -> Decision: trail: list[Step] = [] for link in self._links: name = link.check.name budget = Budget(link.timeout_ms) try: opinion = link.check.check(p, budget) outcome = "pass" if opinion is None else opinion.verdict.value.lower() except CheckFailed as f: if link.on_failure is OnFailure.CLOSED: opinion = Opinion(Verdict.DECLINE, f"{f}, failing closed") outcome = "failed closed" else: opinion = None outcome = "failed open" trail.append(Step(name, outcome, budget.used_ms)) if opinion is not None: return Decision(p.id, opinion.verdict, name, opinion.reason, self.version, tuple(trail)) # Only reachable if the last check can pass, which is a config bug. Fail safe. return Decision(p.id, Verdict.HOLD, "chain", "no check decided", self.version, tuple(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 reference assignment, which is atomic in# Python. Nothing is locked or drained on the request path.class ChainHolder: def __init__(self, first: Chain): self._current = first
def current(self) -> Chain: return self._current
def swap(self, nxt: Chain) -> None: self._current = nxt
counter = AttemptCounter()review_queue: deque[str] = deque()
# Cheap, high-yield checks first; the slow model only sees what they let through.def build(version: int, model_on_failure: OnFailure) -> Chain: return Chain( version, [ Link(Blocklist({"c-666"}), 5, OnFailure.CLOSED), # in memory: if it can't answer, stop Link(Velocity(counter, 3), 10, OnFailure.OPEN), # a counter outage shouldn't stop all sales Link(RiskModel(0.80, 0.30), 50, model_on_failure), Link(ManualReview(500, review_queue), 20, OnFailure.CLOSED), ], )
def show(d: Decision) -> None: print(d) print(d.trail_line())
holder = ChainHolder(build(1, OnFailure.OPEN))
show(holder.current().decide(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 30msshow(holder.current().decide(Payment("P-2", "c-666", 900, 0.20, 30)))# P-2 DECLINE by blocklist: card on blocklist (v1)# blocklist decline 0msshow(holder.current().decide(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 2msshow(holder.current().decide(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.for i in range(1, 5): last = holder.current().decide(Payment(f"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...in_flight = holder.current()# ...while the team flips the model to fail closed. v2 is built whole, then swapped in.holder.swap(build(2, OnFailure.CLOSED))print(holder.current().decide(Payment("P-5", "c-555", 120, 0.95, 80)))# P-5 DECLINE by model: timeout after 50 ms, failing closed (v2)print(in_flight.decide(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.print(holder.current().decide(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# locked increments let exactly 3 attempts through.def worker() -> list[Decision]: return [holder.current().decide(Payment("X", "c-999", 1, 0.50, 20)) for _ in range(100)]
with ThreadPoolExecutor(max_workers=8) as pool: results = [d for f in [pool.submit(worker) for _ in range(8)] for d in f.result()]approved = sum(d.verdict is Verdict.APPROVE for d in results)by_velocity = sum(d.by == "velocity" for d in results)print(f"800 payments on 8 threads: {approved} approved, {by_velocity} declined by velocity")# 800 payments on 8 threads: 3 approved, 797 declined by velocityprint(f"review queue: [{', '.join(review_queue)}]") # review queue: [P-3]using System;using System.Collections.Concurrent;using System.Collections.Generic;using System.Globalization;using System.Linq;using System.Threading;using System.Threading.Tasks;
public 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.public sealed 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".public sealed record Opinion(Verdict Verdict, string Reason);
// A check that can't answer: a timeout, or a dependency that is down.public sealed class CheckFailedException : Exception{ public CheckFailedException(string message) : base(message) { }}
// The time one check may spend. Real code hands a CancellationToken with a timeout to the// client it calls; this one counts fake milliseconds.public sealed class Budget{ public int LimitMs { get; } public int UsedMs { get; private set; } public Budget(int limitMs) => LimitMs = limitMs;
public void Spend(int ms) { if (UsedMs + ms > LimitMs) { UsedMs = LimitMs; // we waited the whole budget, then gave up throw new CheckFailedException($"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.public interface ICheck{ string Name { get; } Opinion? Check(Payment p, Budget budget);}
public sealed class Blocklist : ICheck{ private readonly HashSet<string> _cards; public Blocklist(params string[] cards) => _cards = new HashSet<string>(cards); public string Name => "blocklist"; public Opinion? Check(Payment p, Budget budget) => _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.public sealed class AttemptCounter{ private sealed class Count { public int N; } private readonly ConcurrentDictionary<string, Count> _attempts = new(); public int Record(string card) => Interlocked.Increment(ref _attempts.GetOrAdd(card, _ => new Count()).N);}
public sealed class Velocity : ICheck{ private readonly AttemptCounter _counter; private readonly int _max; public Velocity(AttemptCounter counter, int max) { _counter = counter; _max = max; } public string Name => "velocity";
public Opinion? Check(Payment p, Budget budget) { 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".public sealed class RiskModel : ICheck{ private readonly double _declineAt, _approveBelow; public RiskModel(double declineAt, double approveBelow) { _declineAt = declineAt; _approveBelow = approveBelow; } public string Name => "model";
public Opinion? Check(Payment p, Budget budget) { budget.Spend(p.ModelMs); // the remote call; throws if it takes longer than the budget var why = "score " + p.Score.ToString("0.00", CultureInfo.InvariantCulture); 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.public sealed class ManualReview : ICheck{ private readonly int _reviewFrom; private readonly ConcurrentQueue<string> _queue; public ManualReview(int reviewFrom, ConcurrentQueue<string> queue) { _reviewFrom = reviewFrom; _queue = queue; } public string Name => "review";
public Opinion? Check(Payment p, Budget budget) { if (p.Amount < _reviewFrom) return new Opinion(Verdict.APPROVE, "small amount, no clear signal"); budget.Spend(2); // insert into the review queue _queue.Enqueue(p.Id); return new Opinion(Verdict.HOLD, $"${p.Amount} with no clear signal"); }}
// Open: treat a failure as "no opinion" and ask the next check. Closed: decline.public 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.public sealed record Link(ICheck Check, int TimeoutMs, OnFailure OnFailure);
public sealed record Step(string Check, string Outcome, int Ms){ public override string ToString() => $"{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.public sealed record Decision(string Id, Verdict Verdict, string By, string Reason, int Version, IReadOnlyList<Step> Trail){ public override string ToString() => $"{Id} {Verdict} by {By}: {Reason} (v{Version})"; public string TrailLine() => " " + string.Join(" > ", Trail);}
// 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.public sealed class Chain{ public int Version { get; } private readonly IReadOnlyList<Link> _links; public Chain(int version, params Link[] links) { Version = version; _links = links.ToArray(); }
public Decision Decide(Payment p) { var trail = new List<Step>(); foreach (var link in _links) { var name = link.Check.Name; var budget = new Budget(link.TimeoutMs); Opinion? opinion; string outcome; try { opinion = link.Check.Check(p, budget); outcome = opinion is null ? "pass" : opinion.Verdict.ToString().ToLowerInvariant(); } catch (CheckFailedException f) when (link.OnFailure == OnFailure.Closed) { opinion = new Opinion(Verdict.DECLINE, f.Message + ", failing closed"); outcome = "failed closed"; } catch (CheckFailedException) { opinion = null; outcome = "failed open"; } trail.Add(new Step(name, outcome, budget.UsedMs)); if (opinion is not null) return new Decision(p.Id, opinion.Verdict, name, opinion.Reason, Version, 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, 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.public sealed class ChainHolder{ private Chain _current; public ChainHolder(Chain first) => _current = first; public Chain Current => Volatile.Read(ref _current); public void Swap(Chain next) => Volatile.Write(ref _current, next);}
public static class Program{ static readonly AttemptCounter Counter = new(); static readonly ConcurrentQueue<string> ReviewQueue = new();
// Cheap, high-yield checks first; the slow model only sees what they let through. static Chain Build(int version, OnFailure modelOnFailure) => new Chain(version, new Link(new Blocklist("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) { Console.WriteLine(d); Console.WriteLine(d.TrailLine()); }
public static void Main() { var 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... var 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)); Console.WriteLine(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) Console.WriteLine(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. Console.WriteLine(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. var results = new ConcurrentBag<Decision>(); Parallel.For(0, 8, new ParallelOptions { MaxDegreeOfParallelism = 8 }, _ => { for (int i = 0; i < 100; i++) results.Add(holder.Current.Decide(new Payment("X", "c-999", 1, 0.50, 20))); }); int approved = results.Count(d => d.Verdict == Verdict.APPROVE); int byVelocity = results.Count(d => d.By == "velocity"); Console.WriteLine($"800 payments on 8 threads: {approved} approved, {byVelocity} declined by velocity"); // 800 payments on 8 threads: 3 approved, 797 declined by velocity Console.WriteLine($"review queue: [{string.Join(", ", ReviewQueue)}]"); // review queue: [P-3] }}package main
import ( "fmt" "strings" "sync" "sync/atomic")
type Verdict string
const ( Approve Verdict = "APPROVE" Decline Verdict = "DECLINE" Hold Verdict = "HOLD" // wait for a person to look)
// Payment carries the model's score and latency as test data, so every run prints the same.type Payment struct { ID, Card string Amount int Score float64 ModelMs int}
// Opinion is what a check says. A check returns nil for "no opinion: ask the next one".type Opinion struct { Verdict Verdict Reason string}
// Budget is the time one check may spend. Real code passes a context.Context with a// deadline to the client it calls; this one counts fake milliseconds.type Budget struct{ LimitMs, UsedMs int }
// Spend returns an error when the check can't answer in time.func (b *Budget) Spend(ms int) error { if b.UsedMs+ms > b.LimitMs { b.UsedMs = b.LimitMs // we waited the whole budget, then gave up return fmt.Errorf("timeout after %d ms", b.LimitMs) } b.UsedMs += ms return nil}
// Check is one link's logic. Checks are shared by every request goroutine, so they must be// safe to call concurrently: no per-request fields, and shared state only in safe stores.type Check interface { Name() string Check(p Payment, b *Budget) (*Opinion, error)}
type Blocklist struct{ cards map[string]bool }
func (Blocklist) Name() string { return "blocklist" }func (c Blocklist) Check(p Payment, b *Budget) (*Opinion, error) { if c.cards[p.Card] { return &Opinion{Decline, "card on blocklist"}, nil } return nil, nil}
// AttemptCounter counts attempts per card. It lives outside the chain, so swapping in a// new chain keeps the counts, and every goroutine writes to it, so it holds a mutex.type AttemptCounter struct { mu sync.Mutex attempts map[string]int}
func (c *AttemptCounter) Record(card string) int { c.mu.Lock() defer c.mu.Unlock() c.attempts[card]++ return c.attempts[card]}
type Velocity struct { counter *AttemptCounter max int}
func (Velocity) Name() string { return "velocity" }func (v Velocity) Check(p Payment, b *Budget) (*Opinion, error) { if err := b.Spend(1); err != nil { // one round trip to the shared counter store return nil, err } if n := v.counter.Record(p.Card); n > v.max { return &Opinion{Decline, fmt.Sprintf("%d attempts on this card", n)}, nil } return nil, nil}
// RiskModel decides only when it is confident. The middle band is "no opinion".type RiskModel struct{ declineAt, approveBelow float64 }
func (RiskModel) Name() string { return "model" }func (m RiskModel) Check(p Payment, b *Budget) (*Opinion, error) { if err := b.Spend(p.ModelMs); err != nil { // the remote call; fails past the budget return nil, err } why := fmt.Sprintf("score %.2f", p.Score) switch { case p.Score >= m.declineAt: return &Opinion{Decline, why}, nil case p.Score < m.approveBelow: return &Opinion{Approve, why}, nil } return nil, nil}
// ReviewQueue is where held payments wait for a person. Goroutines append concurrently.type ReviewQueue struct { mu sync.Mutex ids []string}
func (q *ReviewQueue) Add(id string) { q.mu.Lock() defer q.mu.Unlock() q.ids = append(q.ids, id)}
// ManualReview is the last check and 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.type ManualReview struct { reviewFrom int queue *ReviewQueue}
func (ManualReview) Name() string { return "review" }func (r ManualReview) Check(p Payment, b *Budget) (*Opinion, error) { if p.Amount < r.reviewFrom { return &Opinion{Approve, "small amount, no clear signal"}, nil } if err := b.Spend(2); err != nil { // insert into the review queue return nil, err } r.queue.Add(p.ID) return &Opinion{Hold, fmt.Sprintf("$%d with no clear signal", p.Amount)}, nil}
// OnFailure: open treats a failure as "no opinion" and asks the next check; closed declines.type OnFailure int
const ( FailOpen OnFailure = iota FailClosed)
// Link holds chain configuration, not check logic: where a check sits and how it fails.// The same model check can fail open in one chain version and closed in the next.type Link struct { Check Check TimeoutMs int OnFailure OnFailure}
type Step struct { Check, Outcome string Ms int}
func (s Step) String() string { return fmt.Sprintf("%s %s %dms", s.Check, s.Outcome, s.Ms) }
// Decision is kept for audits and disputes: what was decided, by which check, why, under// which chain version, and everything each check said on the way.type Decision struct { ID string Verdict Verdict By string Reason string Version int Trail []Step}
func (d Decision) String() string { return fmt.Sprintf("%s %s by %s: %s (v%d)", d.ID, d.Verdict, d.By, d.Reason, d.Version)}
func (d Decision) TrailLine() string { parts := make([]string, len(d.Trail)) for i, s := range d.Trail { parts[i] = s.String() } return " " + strings.Join(parts, " > ")}
// Chain is immutable: built whole and never edited, so any number of goroutines can run it// at once without locks. A reload builds a new Chain instead of changing this one.type Chain struct { version int links []Link}
func NewChain(version int, links ...Link) *Chain { return &Chain{version, append([]Link(nil), links...)}}
func (c *Chain) Decide(p Payment) Decision { var trail []Step for _, link := range c.links { name := link.Check.Name() budget := &Budget{LimitMs: link.TimeoutMs} opinion, err := link.Check.Check(p, budget) outcome := "pass" switch { case err != nil && link.OnFailure == FailClosed: opinion = &Opinion{Decline, err.Error() + ", failing closed"} outcome = "failed closed" case err != nil: opinion = nil outcome = "failed open" case opinion != nil: outcome = strings.ToLower(string(opinion.Verdict)) } trail = append(trail, Step{name, outcome, budget.UsedMs}) if opinion != nil { return Decision{p.ID, opinion.Verdict, name, opinion.Reason, c.version, trail} } } // Only reachable if the last check can pass, which is a config bug. Fail safe. return Decision{p.ID, Hold, "chain", "no check decided", c.version, trail}}
// ChainHolder holds the live chain. Each payment loads it once and keeps that snapshot to// the end; a reload publishes a fully built chain with one atomic store. Nothing is locked// or drained on the request path.type ChainHolder struct{ current atomic.Pointer[Chain] }
func (h *ChainHolder) Current() *Chain { return h.current.Load() }func (h *ChainHolder) Swap(next *Chain) { h.current.Store(next) }
var ( counter = &AttemptCounter{attempts: map[string]int{}} reviewQueue = &ReviewQueue{})
// build puts cheap, high-yield checks first; the slow model only sees what they let through.func build(version int, modelOnFailure OnFailure) *Chain { return NewChain(version, Link{Blocklist{map[string]bool{"c-666": true}}, 5, FailClosed}, // in memory: if it can't answer, stop Link{Velocity{counter, 3}, 10, FailOpen}, // a counter outage shouldn't stop all sales Link{RiskModel{0.80, 0.30}, 50, modelOnFailure}, Link{ManualReview{500, reviewQueue}, 20, FailClosed}, )}
func show(d Decision) { fmt.Println(d) fmt.Println(d.TrailLine())}
func main() { holder := &ChainHolder{} holder.Swap(build(1, FailOpen))
show(holder.Current().Decide(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(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(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(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. var last Decision for i := 1; i <= 4; i++ { last = holder.Current().Decide(Payment{fmt.Sprintf("T-%d", 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... inFlight := holder.Current() // ...while the team flips the model to fail closed. v2 is built whole, then swapped in. holder.Swap(build(2, FailClosed)) fmt.Println(holder.Current().Decide(Payment{"P-5", "c-555", 120, 0.95, 80})) // P-5 DECLINE by model: timeout after 50 ms, failing closed (v2) fmt.Println(inFlight.Decide(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. fmt.Println(holder.Current().Decide(Payment{"T-5", "c-444", 1, 0.50, 20})) // T-5 DECLINE by velocity: 5 attempts on this card (v2)
// Eight goroutines share one chain with no locks. Only the counter is shared state, and // its mutex lets exactly 3 attempts through. var approved, byVelocity atomic.Int64 var wg sync.WaitGroup for t := 0; t < 8; t++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < 100; i++ { d := holder.Current().Decide(Payment{"X", "c-999", 1, 0.50, 20}) if d.Verdict == Approve { approved.Add(1) } if d.By == "velocity" { byVelocity.Add(1) } } }() } wg.Wait() fmt.Printf("800 payments on 8 threads: %d approved, %d declined by velocity\n", approved.Load(), byVelocity.Load()) // 800 payments on 8 threads: 3 approved, 797 declined by velocity fmt.Printf("review queue: [%s]\n", strings.Join(reviewQueue.ids, ", ")) // review queue: [P-3]}type 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.type Payment = { id: string; card: string; amount: number; score: number; modelMs: number };const pay = (id: string, card: string, amount: number, score: number, modelMs: number): Payment => ({ id, card, amount, score, modelMs,});
// What a check says. A check returns null for "no opinion: ask the next one".type Opinion = { verdict: Verdict; reason: string };
// A check that can't answer: a timeout, or a dependency that is down.class CheckFailed extends Error {}
// The time one check may spend. Real code hands an AbortSignal with a timeout to the// client it calls; this one counts fake milliseconds.class Budget { usedMs = 0; constructor(readonly limitMs: number) {}
spend(ms: number): void { if (this.usedMs + ms > this.limitMs) { this.usedMs = this.limitMs; // we waited the whole budget, then gave up throw new CheckFailed(`timeout after ${this.limitMs} ms`); } this.usedMs += ms; }}
// One link's logic. Checks are shared by every request in flight, so they hold no// per-request fields, and shared state lives in stores outside the chain.interface Check { readonly name: string; check(p: Payment, budget: Budget): Opinion | null;}
class Blocklist implements Check { readonly name = 'blocklist'; constructor(private readonly cards: ReadonlySet<string>) {} check(p: Payment): Opinion | null { return this.cards.has(p.card) ? { verdict: 'DECLINE', reason: 'card on blocklist' } : null; }}
// Attempts per card. It lives outside the chain, so swapping in a new chain keeps the// counts. Node runs this on one thread, so a Map is enough; the read and the write must// still happen with no await between them.class AttemptCounter { private readonly attempts = new Map<string, number>(); record(card: string): number { const n = (this.attempts.get(card) ?? 0) + 1; this.attempts.set(card, n); return n; }}
class Velocity implements Check { readonly name = 'velocity'; constructor( private readonly counter: AttemptCounter, private readonly max: number, ) {} check(p: Payment, budget: Budget): Opinion | null { budget.spend(1); // one round trip to the shared counter store const n = this.counter.record(p.card); return n > this.max ? { verdict: 'DECLINE', reason: `${n} attempts on this card` } : null; }}
// Decides only when it is confident. The middle band is "no opinion".class RiskModel implements Check { readonly name = 'model'; constructor( private readonly declineAt: number, private readonly approveBelow: number, ) {} check(p: Payment, budget: Budget): Opinion | null { budget.spend(p.modelMs); // the remote call; throws if it takes longer than the budget const why = `score ${p.score.toFixed(2)}`; if (p.score >= this.declineAt) return { verdict: 'DECLINE', reason: why }; if (p.score < this.approveBelow) return { verdict: 'APPROVE', reason: 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 { readonly name = 'review'; constructor( private readonly reviewFrom: number, private readonly queue: string[], ) {} check(p: Payment, budget: Budget): Opinion | null { if (p.amount < this.reviewFrom) return { verdict: 'APPROVE', reason: 'small amount, no clear signal' }; budget.spend(2); // insert into the review queue this.queue.push(p.id); return { verdict: 'HOLD', reason: `$${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.type 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.type Link = { readonly check: Check; readonly timeoutMs: number; readonly onFailure: OnFailure };
type Step = { check: string; outcome: string; ms: number };
// Kept for audits and disputes: what was decided, by which check, why, under which chain// version, and everything each check said on the way.class Decision { constructor( readonly id: string, readonly verdict: Verdict, readonly by: string, readonly reason: string, readonly version: number, readonly trail: readonly Step[], ) {} toString(): string { return `${this.id} ${this.verdict} by ${this.by}: ${this.reason} (v${this.version})`; } trailLine(): string { return ' ' + this.trail.map((s) => `${s.check} ${s.outcome} ${s.ms}ms`).join(' > '); }}
// Immutable: built whole and never edited. A reload builds a new Chain instead of// changing this one, so a request that is part way through never sees a half-made chain.class Chain { private readonly links: readonly Link[]; constructor( readonly version: number, links: Link[], ) { this.links = Object.freeze([...links]); }
decide(p: Payment): Decision { const trail: Step[] = []; for (const link of this.links) { const name = link.check.name; const budget = new Budget(link.timeoutMs); let opinion: Opinion | null; let outcome: string; try { opinion = link.check.check(p, budget); outcome = opinion === null ? 'pass' : opinion.verdict.toLowerCase(); } catch (e) { if (!(e instanceof CheckFailed)) throw e; if (link.onFailure === 'closed') { opinion = { verdict: 'DECLINE', reason: `${e.message}, failing closed` }; outcome = 'failed closed'; } else { opinion = null; outcome = 'failed open'; } } trail.push({ check: name, outcome, ms: budget.usedMs }); if (opinion !== null) return new Decision(p.id, opinion.verdict, name, opinion.reason, this.version, trail); } // Only reachable if the last check can pass, which is a config bug. Fail safe. return new Decision(p.id, 'HOLD', 'chain', 'no check decided', this.version, trail); }}
// Holds the live chain. Each payment reads it once and keeps that snapshot to the end.// With async checks a reload can land between two awaits of one request; the snapshot is// what keeps that request on one version.class ChainHolder { constructor(private chain: Chain) {} current(): Chain { return this.chain; } swap(next: Chain): void { this.chain = next; }}
const counter = new AttemptCounter();const reviewQueue: string[] = [];
// Cheap, high-yield checks first; the slow model only sees what they let through.function build(version: number, modelOnFailure: OnFailure): Chain { return new Chain(version, [ { check: new Blocklist(new Set(['c-666'])), timeoutMs: 5, onFailure: 'closed' }, // in memory: if it can't answer, stop { check: new Velocity(counter, 3), timeoutMs: 10, onFailure: 'open' }, // a counter outage shouldn't stop all sales { check: new RiskModel(0.8, 0.3), timeoutMs: 50, onFailure: modelOnFailure }, { check: new ManualReview(500, reviewQueue), timeoutMs: 20, onFailure: 'closed' }, ]);}
function show(d: Decision): void { console.log(String(d)); console.log(d.trailLine());}
const holder = new ChainHolder(build(1, 'open'));
show(holder.current().decide(pay('P-1', 'c-111', 40, 0.1, 30)));// P-1 APPROVE by model: score 0.10 (v1)// blocklist pass 0ms > velocity pass 1ms > model approve 30msshow(holder.current().decide(pay('P-2', 'c-666', 900, 0.2, 30)));// P-2 DECLINE by blocklist: card on blocklist (v1)// blocklist decline 0msshow(holder.current().decide(pay('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 2msshow(holder.current().decide(pay('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.let last: Decision | null = null;for (let i = 1; i <= 4; i++) last = holder.current().decide(pay(`T-${i}`, 'c-444', 1, 0.5, 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...const inFlight = holder.current();// ...while the team flips the model to fail closed. v2 is built whole, then swapped in.holder.swap(build(2, 'closed'));console.log(String(holder.current().decide(pay('P-5', 'c-555', 120, 0.95, 80))));// P-5 DECLINE by model: timeout after 50 ms, failing closed (v2)console.log(String(inFlight.decide(pay('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.console.log(String(holder.current().decide(pay('T-5', 'c-444', 1, 0.5, 20))));// T-5 DECLINE by velocity: 5 attempts on this card (v2)
// Node has one thread, so the other languages' "8 threads" is 800 payments in a row here.// The counter still lets exactly 3 attempts through.let approved = 0;let byVelocity = 0;for (let i = 0; i < 800; i++) { const d = holder.current().decide(pay('X', 'c-999', 1, 0.5, 20)); if (d.verdict === 'APPROVE') approved++; if (d.by === 'velocity') byVelocity++;}console.log(`800 payments on 8 threads: ${approved} approved, ${byVelocity} declined by velocity`);// 800 payments on 8 threads: 3 approved, 797 declined by velocityconsole.log(`review queue: [${reviewQueue.join(', ')}]`); // review queue: [P-3]What to notice
Section titled “What to notice”- 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 + .... Herecis a check’s cost, andpthe 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 byc / 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.getwith a timeout, acontext.Context, aCancellationToken, anAbortSignal). - Fail open or closed is decided per check, in the chain’s config. The check only reports that
it failed. The
Linksays 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.
Decisionholds 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
Chainis 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-444is on its fifth attempt under v2. If the counter lived insideVelocity, 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
Budgetand trail are its own. Only the counter is shared, so its increment is atomic (ConcurrentHashMapwithAtomicInteger,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.
ManualReviewnever passes, so “nobody decided” can only happen through a configuration mistake. The chain still handles it: it holds the payment rather than approving it.
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.
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.
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.
Tradeoffs
Section titled “Tradeoffs”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.
In each language
Section titled “In each language”| 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.
Commonly confused with
Section titled “Commonly confused with”| 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. |
Where it shows up
Section titled “Where it shows up”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.
Interview questions
Section titled “Interview questions”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 |
Further reading
Section titled “Further reading”- 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
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.