Chain of Responsibility

A refund comes in for $2,400. Who is allowed to approve it?

On the first version of our billing tool, the answer lived in one function, and that function grew a new else if every time somebody got promoted or a limit moved. By the time I inherited it, it read like this:

function approveRefund(refund, agent) {
  if (refund.amount <= 10) {
    return { by: "auto", note: "under auto-approve limit" };
  } else if (refund.amount <= 100) {
    if (agent.role === "lead") {
      return { by: agent.id, note: "lead approval" };
    } else {
      return escalateToLead(refund);
    }
  } else if (refund.amount <= 1000) {
    if (managerOnDuty()) {
      return { by: "manager", note: "manager approval" };
    } else {
      return queueForManager(refund);
    }
  } else {
    return sendToFinance(refund);
  }
}

Read it once and it looks fine. Live with it for a month and the cracks show.

The approval limits are welded into the branching, so you cannot see the tiers without tracing every arm. Adding one (“directors approve up to $10k, the board above that”) means splicing another else if into the middle and re-indenting the ones below it. You cannot test the manager tier on its own, because reaching it requires setting up every condition above it. And the day someone decides a fraud check must run before any approval, that check has to be pasted into all four arms.

None of that is really an if problem. It is a coupling problem. Five independent decisions about who handles a refund got fused into one body, so touching one means touching all of them. What you actually have is a list of people, each able to approve up to a limit, tried in order until one says yes. The code just doesn’t look like that.

Make the chain a chain

Look at what each arm really is: a small function that either approves the refund or declines and lets the next person look. So write it that way. Each approver is one handler.

// each handler: approve the refund, or return null to pass it on
const autoApprove = (refund) =>
  refund.amount <= 10 ? { by: "auto", note: "under limit" } : null;

const leadApprove = (refund) =>
  refund.amount <= 100 ? { by: "lead", note: "lead signed off" } : null;

const managerApprove = (refund) =>
  refund.amount <= 1000 ? { by: "manager", note: "manager signed off" } : null;

const financeApprove = (refund) =>
  ({ by: "finance", note: "escalated to finance" }); // catch-all, always answers

Now a runner walks the list and stops at the first handler that returns something:

function handle(refund, chain) {
  for (const handler of chain) {
    const decision = handler(refund);
    if (decision) return decision;   // someone took it, stop walking
  }
  return null;                       // fell off the end, nobody could
}

const approvers = [autoApprove, leadApprove, managerApprove, financeApprove];

handle({ amount: 500 }, approvers);  // { by: "manager", note: "manager signed off" }
handle({ amount: 6 }, approvers);    // { by: "auto",    note: "under limit" }

That for loop is the entire pattern. Return a value means “I handled it, stop.” Return null means “not mine, keep going.” The array decides the order; the loop decides the fall-through. Adding the director tier is now one line inserted into the array, and every approver already in it stays exactly as it was.

each approver asks is this mine, and passes it on if notrefund $500names no oneautoup to $10leadup to $100managerup to $1000financenever askedpasspass$500 within $1000approve, stop
The refund walks the line of approvers. Each asks is this mine, and passes it on if not, until one takes it. The refund never names an approver.

The refund object carries no idea of who will approve it. It flows in one end, and somewhere down the line a handler catches it. That decoupling is the whole point: the sender does not know the receiver. You can add approvers, remove them, or reorder them, and the code that submits a refund never changes.

The textbook shape, and why JavaScript skips it

If you learned this pattern from a Java or C# book, you met it wearing more clothes. There was an abstract Handler class with a setNext method, each concrete handler holding a reference to the next one, and a handle method that either dealt with the request or forwarded it down the link.

You can write that in JavaScript. It looks like this:

class Approver {
  constructor(limit, name) {
    this.limit = limit;
    this.name = name;
    this.next = null;
  }
  setNext(handler) {
    this.next = handler;
    return handler;          // return next so calls can be chained
  }
  handle(refund) {
    if (refund.amount <= this.limit) return { by: this.name };
    if (this.next) return this.next.handle(refund);
    return null;
  }
}

const auto = new Approver(10, "auto");
auto.setNext(new Approver(100, "lead")).setNext(new Approver(1000, "manager"));

auto.handle({ amount: 500 });   // { by: "manager" }

It works. It is also ceremony. The next pointer, the setNext builder, the base class: every bit of that machinery exists to line functions up in an order and walk them one at a time. An array already does that, and a for loop already walks it. Those languages needed the linked structure because for a long time they could not pass behavior around on its own, only objects. JavaScript never had that limit. A handler is a function, so the chain is a list of functions and the runner is a loop.

Two flavors of the same idea

There is a fork in this pattern, and knowing which side you are on saves a lot of confusion.

The refund chain is the pure flavor: exactly one handler ultimately deals with the request, and everyone else passes. The request is looking for its owner. A support ticket escalating until someone with the right access closes it, a parser trying each rule until one matches, a DOM event bubbling up until an ancestor catches it. One winner, the rest decline.

The other flavor is the pipeline, and you have almost certainly shipped it. Every handler does a little work and then calls the next one: log the request, check the token, parse the body, and only then reach the thing that answers. Nobody is looking for an owner. Each layer contributes and hands control on. That is middleware, and if you have written Express, Koa or Hono you have built this chain by hand without calling it a pattern.

Pure chain of responsibility, exactly one handler answershandler 1handler 2answers, stophandler 3handler 4passnever runsnever runsMiddleware pipeline, every handler contributes then calls nextlogauthparsehandlernext()next()response returns back out through every layer
Pure chain: one handler answers and the rest never run. Pipeline: every handler contributes, then calls the next, and control returns back out.

The pipeline flavor has its own whole lesson, because that “back out through every layer” part hides sharp edges: what next() returns, why order is the program, and how a forgotten return ships an auth bypass. That is the middleware onion, and I am not going to re-teach it here. What matters for us is that both pictures are the same family. A request enters a line of handlers, and each handler decides whether to act, whether to pass, or both.

The bug the pattern invites

Here is the failure mode, and it is quiet enough to reach production.

In the pipeline flavor, each handler is responsible for calling next. Forget it, and the chain stops dead where you stood. Everything downstream, including the handler that was supposed to answer, never runs. The request just hangs.

const chain = [
  (ctx, next) => { ctx.log.push("auth"); next(); },
  (ctx, next) => { ctx.log.push("parse"); /* forgot next() */ },
  (ctx) => { ctx.body = "the actual answer"; },  // never reached
];

The linked-list version has the same disease wearing different clothes. A handler that can’t deal with the request has to remember to forward it, and if you write the “I can’t help” branch and forget the this.next.handle(refund) line, the request dies at that node even though a later handler would have taken it.

handle(refund) {
  if (refund.amount <= this.limit) return { by: this.name };
  // bug: no `return this.next?.handle(refund)` here,
  // so anything over this limit silently falls off with null
}

Both are the same mistake: a handler that neither answers nor passes. The request evaporates, no error is thrown, and the trace that would tell you where it stopped does not exist unless you built one.

handler 2 neither approves nor passesautopassleadreturns nothingmanagernever reachedfinancenever reached$500 refund: stalled at lead, no error, no decision
Handler 2 checks the request, cannot approve it, and forgets to pass it on. The request stalls there. The two handlers that could have taken it are never asked.

This is the strongest reason to prefer the array-and-loop form for pure chains. Look again at the runner:

function handle(refund, chain) {
  for (const handler of chain) {
    const decision = handler(refund);
    if (decision) return decision;
    // advancing to the next handler is the LOOP's job, not the handler's
  }
  return null;
}

The loop owns the advancing. A single handler cannot forget to pass, because passing isn’t its responsibility. Each handler only answers “here is my decision” or “nothing from me,” and the runner does the walking. Centralize the one thing everyone kept getting wrong and nobody can get it wrong.

The pipeline flavor cannot do this, and the reason is instructive. Middleware needs the “after” phase (log the response, record the duration), and the only place to put “after” code is on the two sides of a next() call the handler makes itself. So the pipeline pays for its extra power with the extra risk. The pure chain doesn’t need an after-phase, so it doesn’t need per-handler forwarding, so it can be immune to the bug. Pick the least powerful flavor that does your job.

Order is load-bearing

The chain runs top to bottom, which means the order of the list is the logic. Move a handler and you change who answers, without editing a single handler.

Put the catch-all finance approver at the front of the refund chain and it approves everything, including the $6 refund the bot should have rubber-stamped. Every refund now escalates straight to finance. Put a broad handler before a specific one and the broad one eats requests meant for the specific one.

cheapest-first: the bot takes the small oneautoup to $10leadmanagerfinance$5approved by autonever reachedbiggest-first: finance takes everythingfinanceany amountmanagerleadauto$5approved by financenever reachedsame handlers, different order, different answer
Same four approvers, two orders. Cheapest-first lets the bot take the small refund. Biggest-first sends everything to finance. Nobody edited a handler.

The rule that falls out: put the most specific handler first and the catch-all last. A handler that answers “yes” to a broad range has to sit after the ones that answer to a narrow one, or it steals their requests. The middleware lesson makes the same point from the pipeline side, where order is the program, and it is the single thing most likely to bite you in either flavor.

Play with a chain

Here is the refund chain running. Set an amount, then send it and watch it walk. Toggle an approver off (someone is on leave) and see the request escalate past them, or fall off the end if nobody left can take it. Reorder them and watch the wrong person start catching requests. Flip an approver to “broken” and it stalls the chain right there, exactly like the forgotten pass.

interactiveA refund walking a chain of approvers

Two runs worth doing. Move finance to the top and submit a $5 refund: it approves it, and auto never gets a look. Then put the order back, hit “break” on manager, and submit $500. The trace stops at manager with no decision and no error, which is precisely the stall that has no stack trace in real code.

Where you have already used it

Once you see the shape, it is everywhere.

DOM events are a pure chain. A click on a button travels up through its ancestors, and the first one with a listener that calls stopPropagation takes it. Reach the top with nobody catching, and the event goes unhandled. That is chain of responsibility, built into the platform, and event bubbling is the mechanism.

document.querySelector(".menu").addEventListener("click", (e) => {
  const item = e.target.closest("[data-action]");
  if (!item) return;          // not mine, let it keep bubbling
  runAction(item.dataset.action);
  e.stopPropagation();        // I took it, stop the chain
});

Event delegation, the trick of putting one listener on a parent instead of many on the children, is nothing but registering a single handler high up the chain and letting bubbling bring the requests to it.

Validation is a chain. Run a list of checks over an input; stop at the first that fails (pure chain, one handler answers “invalid”) or run all of them and collect every error (pipeline, every handler contributes). Both are the same list-of-handlers idea, and which one you want depends on whether you show one error or all of them. Validating input goes deeper on the trade-off.

const rules = [
  (v) => (v.email ? null : "email is required"),
  (v) => (v.email?.includes("@") ? null : "email looks wrong"),
  (v) => (v.age >= 18 ? null : "must be 18 or older"),
];

// pure chain: first failure wins
const firstError = (v) => handle(v, rules);

HTTP client interceptors are a chain. Each interceptor adds a header, logs the request, or retries on a failure, then passes the request along. The response comes back through the same interceptors in reverse, which is the pipeline flavor again.

Approval and escalation flows are a chain. The refund example is the small version. The large version routes a decision up a ladder until it reaches someone with the authority (or the appetite) to sign off, and the top of the chain is often a person, not code. That handoff to a human is its own subject in human in the loop.

Neighbors and lookalikes

A few patterns stand close enough to be confused with this one.

Strategy picks exactly one handler up front, by a key, and calls it. Chain of responsibility lines several up and lets each decide. Reach for strategy when you can look the right handler up in a map (handlers[type]); reach for a chain when which handler applies has to be discovered by asking each one in turn. If your chain’s handlers all begin with a cheap “is this mine?” that maps to a single key, you may have written a slow lookup table. Use the map.

The decorator pattern also stacks and wraps, but its intent is the opposite of routing. A decorator adds behavior to one target and always calls through to it, presenting the same interface the whole way down. A chain’s handler may answer and stop, and the caller does not know which one did. Decorator wraps to enhance; chain lines up to route. (Do not confuse either with JavaScript’s decorator syntax, which is a different thing again.)

A pipeline is the chain with the short-circuit taken out: every stage runs, transforms the data, and passes its output to the next. The middleware onion is a pipeline that also gives you the trip back out.

When not to reach for it

This pattern earns its keep when the set of handlers is open, their order matters, and the request genuinely has to be offered around until one takes it. Take away any of those and it becomes overhead.

If there is exactly one handler, it is a function. A chain of one link is theater. Call the function.

If you always know which handler applies, do not make the request go door to door to find out. A Map keyed by type finds the handler in one step and says out loud which one it picked. Walking a chain to reach an obvious answer is slower and, worse, hides the routing that a lookup would have made plain.

Watch for the fake decoupling, too. The pattern promises that handlers are independent and reorderable. If handler three secretly relies on state that handler two stuffed into the shared context, they are not independent; they are coupled through a side channel, and the first person who reorders them for a good reason gets a mystery bug. A chain whose order you cannot actually change is a pipeline with hidden wires, and you should either make the dependencies explicit or stop pretending the handlers are interchangeable.

Summary

  • Chain of responsibility passes a request along a line of handlers, each of which can handle it, pass it on, or both, so the sender never names the receiver. If you have written middleware, you have shipped this pattern.
  • It decouples the code that submits a request from the code that answers it, which is what lets you add, remove and reorder handlers without touching the sender.
  • Two flavors. The pure chain has one winner and the rest pass (event bubbling, escalation, a first-match parser). The pipeline has every handler contribute and call next (auth, logging, the middleware onion). Pick the least powerful one that does the job.
  • In JavaScript a handler is a function and the chain is an array. The Gang of Four linked-list of handler objects, with setNext and a base class, is ceremony you rarely need. Keep it only for handlers spliced in by reference at runtime.
  • The bug is a handler that neither answers nor passes. In the pipeline flavor it is a forgotten next(); in the linked flavor it is a forgotten delegate call. The request stalls silently. A central for-loop runner makes the pure chain immune, because advancing is the loop’s job, not the handler’s.
  • Order is the program. Most specific handler first, catch-all last. A broad handler placed early eats requests meant for a narrow one, and nobody has to edit a handler for the behavior to change.
  • Build a trace. A long dynamic chain without one turns “which handler took this?” into archaeology, the same problem observability exists to solve.
  • Use it when the set of handlers is open, order matters, and a request must be offered around until one takes it. Avoid it when there is one handler (call the function), when you always know who handles it (use a strategy lookup), or when the handlers are secretly coupled through shared state and cannot really be reordered.