GUIDE · Behavioral

Chain of Responsibility

Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle it; chain the receivers and pass the request along until one handles it.

lesson2 sectionsruns in browserBehavioral12 asserts98 lines

At a glance

INTENT
Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle it; chain the receivers and pass the request along until one handles it.
PROBLEM
Rate limiting, auth, validation, logging — each should be a self-contained step that can handle the request, stop everything, or pass it on, without one giant if/else in the route.
SAY
Participants: Handler (interface: setNext(), handle()), ConcreteHandler (handles or forwards), Client (assembles the chain, fires the request). Pitch: "a pipeline where each link decides: handle, pass along, or stop."
SEEN IN
Express / Koa / Fastify middleware ((req, res, next)); axios & fetch interceptors; Redux middleware (store => next => action); DOM event bubbling + stopPropagation(); try/catch propagating up the call stack; Webpack loader chains; logger level filters.
VS
Decorator ALWAYS forwards and wraps behavior around the call; CoR links may STOP the chain and typically one (or none) handles. Composite is a tree that fans out; CoR is linear. Command is the request object itself; CoR is who gets to process it.

The idea#

Picture an assembly line for lunch trays. The first station counts trays and pulls one off when you have sent too many. The next station looks for a name and stops the tray if the name is missing. The last station bags the lunch. Any station can handle the tray, pass it on, or stop the line. The bagging station does not know the counter exists.

A chain of responsibility is that line. You do not couple the sender of a request to one receiver. You give more than one object a chance. You link them, and you pass the request along until one of them handles it, or until the line runs out. The handler interface is setNext and handle. A concrete handler either finishes the request or forwards it. The client builds the line and fires one request. Rate limiting, auth, validation, and logging belong in their own stations, not in one giant if inside the route.

Why it matters#

The bug this pattern prevents is a route that knows every policy. If the limit, the auth check, and the path check live in one function, every change risks the other checks. A station that only sees the request, the response, and the next station can be clipped in, pulled out, or reordered.

You already run lines like this. Express, Koa, and Fastify middleware take the request and a next function. Axios and fetch interceptors do the same around HTTP. Redux middleware is a chain around dispatch. DOM events bubble until something calls stopPropagation. A try/catch that does not catch lets the error walk up the stack. Webpack runs loaders in a chain. A logger filter is a station that drops quiet messages.

A decorator always forwards. It wraps extra behavior around a call that still happens. A link in this chain may stop the line, and often one station, or none, is the one that actually handles the request. A composite is a tree that fans out to many children. This chain is a straight line. A command is the request turned into an object you can store. This pattern is about who is allowed to process the request, not about boxing the request itself.

A small example#

JS
function station(check, next) {  return function tray(item) {    return check(item, next);  };} function countTrays(limit) {  let seen = 0;  return function check(item, next) {    seen += 1;    if (seen > limit) return 'step off the line';    return next(item);  };} function bag(item) {  return 'bagged ' + item.name;} const line = station(countTrays(2), bag);const first = line({ name: 'book' });const second = line({ name: 'apple' });

first is bagged book. second is bagged apple. A third tray would stop at the counter and never reach bag. The bagging function has no idea the counter is there.

What the code below is doing#

The sheet shows the same idea twice.

The first shape uses classes so the book names are visible. Handler forwards to #next when there is one, and otherwise returns the response unchanged. setNext stores the follower and returns it, so a chain reads left to right. RateLimitHandler counts by ip and, past the limit, returns status 429 with too many requests. AuthHandler wants a bearer token. Missing, it returns 401. Present, it copies the request, sets user.id to the token, and forwards. ValidateHandler demands a string sku on a POST and otherwise returns 400. RouteHandler is the end of the line. It returns 201 and a body that names the sku and the user id.

The line is rate limit 2, then auth, then validate, then the route. The first POST from 10.0.0.1 with token u7 and sku A1 comes back order A1 for u7. A second call from that ip with empty headers is 401. The limiter counted it, then auth stopped the line. A third call from that same ip, even one with an empty body, is 429. It never reaches validation, because the count is already 3 and the limit is 2. A new ip, 10.0.0.2, with an empty body gets 400, sku required. A bare Handler with nobody clipped on returns the response you handed it.

The second shape is a middleware array with next, the way Express and Koa write the line. compose walks the array by index. Calling next twice from the same station throws. The logger records the path on the way in and the status on the way back, so it sees the final status even when a later station stopped the line. Auth writes the token onto the context or sets 401 and returns without calling next. The api station forwards only when the path starts with /api, and otherwise sets 404. The last station sets 200 and hello plus the user.

A call to /api/me with token ada finishes at 200 with body hello ada. The trace is the path on the way in and 200 on the way out. The same path with no token is 401, and the handler never ran, but the trace still shows 401 because the logger stayed on the line. /nope with a token is 404. A tiny line that calls next twice throws the multiple-call error.

When you talk about it, say this: a pipeline where each link decides to handle, pass along, or stop.

Everything below is the demonstration. Same idea, written as code you can run.

JSsystem-design/design-patterns/behavioral/chain-of-responsibility.js · setup
'use strict';const assert = require('node:assert/strict');
01

Classic shape (classes, so the participant names are visible)#

JS
class Handler {                                           // Handler — default behavior: forward  #next = null;  setNext(handler) { this.#next = handler; return handler; }   // returns next so chains read left→right  handle(req, res) { return this.#next ? this.#next.handle(req, res) : res; }}class RateLimitHandler extends Handler {                 // ConcreteHandler — may stop the chain  constructor(limit) { super(); this.limit = limit; this.seen = new Map(); }  handle(req, res) {    const n = (this.seen.get(req.ip) ?? 0) + 1;    this.seen.set(req.ip, n);    if (n > this.limit) return { ...res, status: 429, body: 'too many requests' };    return super.handle(req, res);  }}class AuthHandler extends Handler {                      // ConcreteHandler — enriches and forwards  handle(req, res) {    const token = req.headers.authorization?.replace('Bearer ', '');    if (!token) return { ...res, status: 401, body: 'unauthorized' };    return super.handle({ ...req, user: { id: token } }, res);  }}class ValidateHandler extends Handler {                  // ConcreteHandler  handle(req, res) {    if (req.method === 'POST' && typeof req.body?.sku !== 'string') return { ...res, status: 400, body: 'sku required' };    return super.handle(req, res);  }}class RouteHandler extends Handler {                     // terminal ConcreteHandler  handle(req, res) { return { ...res, status: 201, body: `order ${req.body.sku} for ${req.user.id}` }; }} const chain = new RateLimitHandler(2);chain.setNext(new AuthHandler()).setNext(new ValidateHandler()).setNext(new RouteHandler());const base = { ip: '10.0.0.1', method: 'POST', headers: { authorization: 'Bearer u7' }, body: { sku: 'A1' } };const res0 = { status: 200, body: '' };
Verified by 5 asserts
JS
assert.deepEqual(chain.handle(base, res0), { status: 201, body: 'order A1 for u7' });       // all links passedassert.deepEqual(chain.handle({ ...base, headers: {} }, res0), { status: 401, body: 'unauthorized' });   // stopped at authassert.deepEqual(chain.handle({ ...base, body: {} }, res0), { status: 429, body: 'too many requests' }); // 3rd hit from same ipassert.deepEqual(chain.handle({ ...base, ip: '10.0.0.2', body: {} }, res0), { status: 400, body: 'sku required' });assert.deepEqual(new Handler().handle(base, res0), res0);                                   // empty chain: unchanged
02

Idiomatic JS shape: Express/Koa-style middleware array with next()#

JS
const compose = (middlewares) => (ctx) => {  let i = -1;  const dispatch = (idx) => {    if (idx <= i) throw new Error('next() called multiple times');    i = idx;    const mw = middlewares[idx];    return mw ? mw(ctx, () => dispatch(idx + 1)) : ctx;  };  return dispatch(0);}; const trace = [];const logger = (ctx, next) => { trace.push(`→ ${ctx.path}`); const out = next(); trace.push(`← ${ctx.status}`); return out; };const auth = (ctx, next) => { if (!ctx.token) { ctx.status = 401; return ctx; } ctx.user = ctx.token; return next(); };const apiOnly = (ctx, next) => (ctx.path.startsWith('/api') ? next() : Object.assign(ctx, { status: 404 }));const handler = (ctx) => Object.assign(ctx, { status: 200, body: `hello ${ctx.user}` }); const app = compose([logger, auth, apiOnly, handler]); const okCtx = app({ path: '/api/me', token: 'ada', status: 0 }); trace.length = 0; const doubleNext = compose([(ctx, next) => { next(); return next(); }, (ctx) => ctx]);
Verified by 7 asserts
JS
assert.equal(okCtx.status, 200);assert.equal(okCtx.body, 'hello ada');assert.deepEqual(trace, ['→ /api/me', '← 200']);         // onion: logger sees the final status on the way outassert.equal(app({ path: '/api/me', status: 0 }).status, 401);   // auth short-circuited; handler never ranassert.deepEqual(trace, ['→ /api/me', '← 401']);assert.equal(app({ path: '/nope', token: 'x', status: 0 }).status, 404);assert.throws(() => doubleNext({}), /next\(\) called multiple times/);   // the classic Koa guard

See also