Skip to content
aipatterns.dev

Design patternBehavioral Patterns

Chain of Responsibility

Pass a request along a chain of handlers until one decides it, so the sender never knows which handler acts or how many exist.

Complexity
Medium
Seen in the wild
Low
Also known as
CoR, Chain of Command

Intent

Give several objects a chance to handle a request by linking them into a chain and passing the request along it. Each handler either decides the request or forwards it to the next one. The sender talks to the head of the chain and never learns who answered.

Forces

  • One entry point for the sender vs many rules that each own part of the decision: callers want one call, but the logic belongs to separate checks.
  • Rule order matters vs rules should not know each other: a duplicate check must run before auto-approval, yet neither should reference the other.
  • Different chains per context vs one hard-coded if/else ladder: retail and marketplace orders need different rule sets, assembled at startup.
  • Work around the decision vs work inside it: logging, timing or auditing must wrap whatever the later handlers decide.

Therefore

Give every handler the same interface and a reference to the next handler, and let each one either return a result or forward the request.

The client builds the chain once, usually at the composition root, and calls the first handler. A handler that cannot decide calls next. A handler can also run code before and after forwarding, which is what makes the linked form different from a plain list of rules. When no handler decides, the end of the chain returns a defined fallback instead of failing silently.

Structure

Diagram source (Mermaid)
classDiagram
  class RefundHandler {
    <<interface>>
    +handle(req) Decision
  }
  class Step {
    <<abstract>>
    -RefundHandler next
    +handle(req) Decision
  }
  class AuditTrail {
    +handle(req) Decision
  }
  class DuplicateGuard {
    +handle(req) Decision
  }
  class FraudHold {
    +handle(req) Decision
  }
  class AutoApprove {
    -Money limit
    +handle(req) Decision
  }
  RefundHandler <|.. Step
  Step o-- RefundHandler : next
  Step <|-- AuditTrail
  Step <|-- DuplicateGuard
  Step <|-- FraudHold
  Step <|-- AutoApprove

Participants

  • Handler (RefundHandler): the one method every link implements. It takes the request and returns the outcome.
  • Base handler (Step): optional. It stores next and supplies the forwarding and end-of-chain fallback, so concrete handlers don't repeat it.
  • Concrete handlers (DuplicateGuard, FraudHold, AutoApprove, AuditTrail): each checks one condition, decides or forwards, and knows nothing about its neighbors.
  • Client: assembles the chain in the right order and sends requests to its head.

Use it when

  • A request goes through a sequence of checks where any one of them may settle it: refund triage, support ticket routing, input validation with early rejection.
  • The set or order of checks differs by tenant, plan, region or environment, and you want to assemble it from configuration.
  • Some handlers must wrap the rest of the chain: audit logging, timing, retries, or transactions around whatever decides.
  • A long if/else ladder in one function keeps growing, and each branch is an independent business rule with its own tests.

Do not use it when

  • Every handler always runs in order and none can stop the flow. That is a pipeline: use an array of functions and a loop, or your framework's middleware stack (Express, Koa, ASP.NET, Go http.Handler wrapping). Most web frameworks already give you this pattern; don't rebuild it.
  • The rules are pure predicates that map to an outcome and nothing wraps the call. A list of (condition, decision) pairs and a find is shorter and easier to read than linked objects.
  • There are two or three fixed checks that never change order or vary by context. Keep the if statements.
  • Exactly one receiver is known at compile time. Call it directly.
  • Several receivers must all react to the event. That is Observer, not a chain.

Application grill

Inspect first: the framework middleware already in place (Express, Koa, Next, Fastify middleware, interceptors, guards) - the codebase may already run this pattern under another name; how handlers decide today to act or pass along.

Missing pieces: usually none: the framework's middleware chain is the pattern, and a plain array of functions covers short chains. A pipeline library pays only when users configure chains at runtime.

Ask before you build:

  • Scale: how many handlers will the chain hold - three steps is an array, fifteen is a framework decision?
  • Performance: does every request traverse every handler even when the first would decide?
  • Reliability: when no handler claims the request, what answers - a terminal default handler or an error?
  • Budget: handler order is invisible at call sites; who owns keeping it correct?
  • Implementation: is the chain order fixed in code or configurable, and is there a test per decision boundary?

Implementation steps

  1. List the rules in the current conditional and confirm each one can either decide or defer on its own.
  2. Define the handler interface: one method that takes the request and returns a decision. Keep the request immutable so handlers can't change it behind each other's backs.
  3. Add a base handler (or a helper function) that holds next and returns a defined fallback when the chain ends.
  4. Move each rule into its own handler. Run the tests after each move.
  5. Build the chain at the composition root, in one place, so the order is visible and reviewable.
  6. Replace the original conditional with one call to the head of the chain.

Example

A store triages refund requests. Duplicates are rejected, flagged customers are held for review, small refunds are approved automatically, and everything else goes to a manager. Marketplace orders use a stricter chain with no auto-approval. Every decision is audited.

interface RefundRequest {
  orderId: string;
  amountCents: number;
  customerFlagged?: boolean;
  alreadyRefunded?: boolean;
}

type Decision = { outcome: "approved" | "rejected" | "held" | "escalated"; reason: string };

interface RefundHandler {
  handle(req: RefundRequest): Decision;
}

abstract class Step implements RefundHandler {
  constructor(private readonly next?: RefundHandler) {}

  abstract handle(req: RefundRequest): Decision;

  protected passOn(req: RefundRequest): Decision {
    if (!this.next) return { outcome: "escalated", reason: "no rule matched; send to a manager" };
    return this.next.handle(req);
  }
}

class DuplicateGuard extends Step {
  handle(req: RefundRequest): Decision {
    return req.alreadyRefunded ? { outcome: "rejected", reason: "order already refunded" } : this.passOn(req);
  }
}

class FraudHold extends Step {
  handle(req: RefundRequest): Decision {
    return req.customerFlagged ? { outcome: "held", reason: "customer under review" } : this.passOn(req);
  }
}

class AutoApprove extends Step {
  constructor(private readonly limitCents: number, next?: RefundHandler) {
    super(next);
  }
  handle(req: RefundRequest): Decision {
    return req.amountCents <= this.limitCents ? { outcome: "approved", reason: "under auto limit" } : this.passOn(req);
  }
}

// Wraps the rest of the chain: it forwards first, then records the result.
class AuditTrail extends Step {
  handle(req: RefundRequest): Decision {
    const decision = this.passOn(req);
    console.log(`[audit] ${req.orderId} -> ${decision.outcome}: ${decision.reason}`);
    return decision;
  }
}

// Order is set here, once. Each handler only knows "next".
const retail = new AuditTrail(new DuplicateGuard(new FraudHold(new AutoApprove(5000))));
const marketplace = new AuditTrail(new DuplicateGuard(new FraudHold()));

retail.handle({ orderId: "A-100", amountCents: 2500 }); // approved
retail.handle({ orderId: "A-101", amountCents: 2500, customerFlagged: true }); // held
retail.handle({ orderId: "A-102", amountCents: 9900 }); // escalated
retail.handle({ orderId: "A-103", amountCents: 900, alreadyRefunded: true }); // rejected
marketplace.handle({ orderId: "M-200", amountCents: 2500 }); // escalated

Trade-offs

Gains

  • The sender is decoupled from the receivers. Adding, removing or reordering a rule touches only the chain assembly.
  • Each rule is a small class or function you can test with a stub next.
  • Different contexts get different chains from the same parts.
  • Handlers can wrap the rest of the chain for auditing, timing or error handling.

Costs

  • A request can fall off the end unhandled. You need an explicit fallback, and tests for it.
  • Order dependencies are real but implicit: moving AutoApprove above FraudHold is a silent bug.
  • Debugging means stepping through every link to find who decided.
  • For pure predicate rules, it is more code than a list and a loop.

Relations

  • Decorator has the same linked shape, but every decorator always forwards and adds behavior. A chain handler may stop the request.
  • Command can be the request object that travels down the chain, which makes requests loggable and replayable.
  • Composite trees often use a chain upward: a UI element that can't handle a click passes it to its parent.
  • Mediator centralizes the routing decision in one object. A chain distributes it across handlers.
  • Observer delivers an event to every subscriber. A chain delivers it until one handler takes it.

Agent checklist

  • At least one handler can stop the request or wraps the rest of the chain. Otherwise use a function array or the framework's middleware.
  • The chain is assembled in one visible place, and the order is covered by a test.
  • The end of the chain returns a defined fallback (escalate, reject, or a typed "unhandled"), never undefined by accident.
  • The request object is immutable, or the handlers that may change it are documented.
  • Each handler has its own test with a stub next, and the original conditional's behavior is preserved, verified by running the existing tests.

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.