A request arrives. It must be authenticated, rate checked, logged, validated, authorized, and only then handled. Written naively, that becomes one enormous function with nested ifs, early returns and cross-cutting concerns tangled into business logic. Adding a step means editing the function; reordering steps means rereading it; testing one step means setting up all the others.

Chain of Responsibility solves this by giving each concern its own small handler and linking handlers so a request travels along the chain until someone deals with it, or everyone has had a look. If you have ever written Express middleware, Koa middleware or an ASP.NET pipeline, you have already used it.

The Pattern

The classic definition: avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle it. Chain the receiving objects and pass the request along the chain until an object handles it.

Each handler has the same interface and a reference to the next handler. It can do one of three things: handle the request and stop, do something and pass it on, or reject it.

interface Handler<Req, Res> {
  setNext(next: Handler<Req, Res>): Handler<Req, Res>;
  handle(request: Req): Promise<Res>;
}

abstract class BaseHandler<Req, Res> implements Handler<Req, Res> {
  private next?: Handler<Req, Res>;

  setNext(next: Handler<Req, Res>): Handler<Req, Res> {
    this.next = next;
    return next; // allows fluent chaining
  }

  async handle(request: Req): Promise<Res> {
    if (this.next) return this.next.handle(request);
    throw new Error("No handler produced a response");
  }
}

This is the textbook object-oriented shape. It works, but in TypeScript there is a lighter and more flexible form, which we will get to. First, a concrete example of the classic version.

Example: A Support Ticket Escalation Chain

Support tickets are routed by severity. A first-line agent handles simple tickets, a specialist handles harder ones, and a manager handles the rest. The sender does not know who will answer.

interface Ticket {
  id: string;
  severity: 1 | 2 | 3 | 4 | 5;
  subject: string;
}

class FirstLine extends BaseHandler<Ticket, string> {
  async handle(ticket: Ticket): Promise<string> {
    if (ticket.severity <= 2) return `First line resolved ${ticket.id}`;
    return super.handle(ticket);
  }
}

class Specialist extends BaseHandler<Ticket, string> {
  async handle(ticket: Ticket): Promise<string> {
    if (ticket.severity <= 4) return `Specialist resolved ${ticket.id}`;
    return super.handle(ticket);
  }
}

class Manager extends BaseHandler<Ticket, string> {
  async handle(ticket: Ticket): Promise<string> {
    return `Manager took ownership of ${ticket.id}`;
  }
}

const chain = new FirstLine();
chain.setNext(new Specialist()).setNext(new Manager());

console.log(await chain.handle({ id: "T-1", severity: 4, subject: "Billing error" }));

The client holds only the head of the chain. Adding a new tier means writing one class and linking it; no existing handler changes. This is the Open/Closed Principle applied to a decision process.

The Functional Form: Middleware

Most real pipelines are not “first handler that answers wins”. They are “every handler does its part, then calls the rest”. That is the middleware model, and in TypeScript it is naturally expressed with functions rather than classes.

type Next = () => Promise<void>;
type Middleware<Ctx> = (ctx: Ctx, next: Next) => Promise<void>;

function compose<Ctx>(middlewares: Middleware<Ctx>[]): (ctx: Ctx) => Promise<void> {
  return async (ctx) => {
    let index = -1;

    const dispatch = async (i: number): Promise<void> => {
      if (i <= index) throw new Error("next() called multiple times");
      index = i;
      const fn = middlewares[i];
      if (!fn) return;
      await fn(ctx, () => dispatch(i + 1));
    };

    await dispatch(0);
  };
}

Fifteen lines, and you have the core of Koa. The index guard catches a classic bug: calling next() twice, which would run the rest of the chain twice. Now define a context and a few middlewares:

interface Context {
  method: string;
  path: string;
  headers: Record<string, string>;
  user?: { id: string; roles: string[] };
  status: number;
  body?: unknown;
}

const logging: Middleware<Context> = async (ctx, next) => {
  const start = performance.now();
  await next();
  const ms = Math.round(performance.now() - start);
  console.log(`${ctx.method} ${ctx.path} -> ${ctx.status} (${ms}ms)`);
};

const authenticate: Middleware<Context> = async (ctx, next) => {
  const token = ctx.headers["authorization"]?.replace("Bearer ", "");
  if (!token) {
    ctx.status = 401;
    ctx.body = { error: "missing_token" };
    return; // short-circuit: next() is not called
  }
  ctx.user = await verifyToken(token);
  await next();
};

const requireRole = (role: string): Middleware<Context> => async (ctx, next) => {
  if (!ctx.user?.roles.includes(role)) {
    ctx.status = 403;
    ctx.body = { error: "forbidden" };
    return;
  }
  await next();
};

const handler: Middleware<Context> = async (ctx) => {
  ctx.status = 200;
  ctx.body = { message: "secret report" };
};

const app = compose<Context>([logging, authenticate, requireRole("admin"), handler]);

Two properties make this powerful. First, any middleware can short-circuit by not calling next(), which is how authentication refuses a request without the handler ever running. Second, code after await next() runs on the way back out, so a middleware wraps everything downstream. The logging middleware measures total time by sitting around the whole chain. That onion shape is what makes cross-cutting concerns such as timing, error handling and transactions easy to express.

requireRole is a middleware factory: a function returning a middleware. Configuration lives in the closure, so the chain reads like a declaration of intent.

Because outer middlewares wrap inner ones, one at the top can catch everything below it:

const errorBoundary: Middleware<Context> = async (ctx, next) => {
  try {
    await next();
  } catch (error) {
    console.error(error);
    ctx.status = 500;
    ctx.body = { error: "internal_error" };
  }
};

const app = compose<Context>([errorBoundary, logging, authenticate, requireRole("admin"), handler]);

Order is part of the design. The error boundary comes first so it covers the rest; logging comes before authentication so rejected requests are still logged. Treat the array as a specification and put a comment above anything whose position is load-bearing.

Relationship to Decorator

Middleware and the Decorator pattern look similar: both wrap behavior around a core. The distinction is intent. A decorator wraps one object to add behavior while keeping its interface, and every decorator delegates. A chain of responsibility passes a request along a sequence in which handlers may decide to stop. If every link always delegates, you effectively have decorators; if links can terminate or branch, you have a chain.

The Same Pattern in Python

Python generators and plain callables make the pipeline compact:

from dataclasses import dataclass, field
from typing import Callable

@dataclass
class Order:
    total: float
    country: str
    errors: list[str] = field(default_factory=list)

Step = Callable[[Order], bool]  # return False to stop the chain

def check_total(order: Order) -> bool:
    if order.total <= 0:
        order.errors.append("total must be positive")
        return False
    return True

def check_country(order: Order) -> bool:
    if order.country not in {"FR", "DE", "ES"}:
        order.errors.append("country not supported")
        return False
    return True

def run_chain(order: Order, steps: list[Step]) -> bool:
    return all(step(order) for step in steps)  # all() stops at the first False

order = Order(total=50.0, country="US")
run_chain(order, [check_total, check_country])
print(order.errors)  # ['country not supported']

all() over a generator short-circuits at the first failing step, which is exactly chain semantics. For richer pipelines, look at how ASGI and WSGI middleware wrap an application callable.

Testing Handlers in Isolation

The payoff of small handlers is that each one is trivial to test. Call it with a fake context and a stub next:

import { describe, it, expect, vi } from "vitest";

describe("authenticate", () => {
  it("rejects requests without a token and does not call next", async () => {
    const ctx = { method: "GET", path: "/", headers: {}, status: 0 } as Context;
    const next = vi.fn();

    await authenticate(ctx, next);

    expect(ctx.status).toBe(401);
    expect(next).not.toHaveBeenCalled();
  });
});

No server, no database, no other middleware. Compare that with testing the same check buried in a hundred-line function.

Building Chains From Configuration

Because handlers share one signature, chains can be assembled per route or per environment rather than hard-coded. A public endpoint might use logging and rate limiting only, while an admin endpoint adds authentication and role checks. A registry mapping names to middleware lets configuration decide the pipeline:

const registry: Record<string, Middleware<Context>> = { logging, authenticate };

function build(names: string[], final: Middleware<Context>) {
  return compose<Context>([...names.map((n) => registry[n]), final]);
}

const publicRoute = build(["logging"], handler);
const adminRoute = build(["logging", "authenticate"], handler);

Keep this lightweight. Once pipelines are defined in a configuration file nobody can read at a glance, the flexibility has turned into the same spaghetti the pattern was meant to remove.

Pitfalls

  • Request falls off the end. In the classic version, if no handler accepts the request, nothing happens or an error appears. Always provide a terminal handler or an explicit default.
  • Forgetting to call next. A middleware that neither responds nor calls next() hangs the request. Lint for it, and test both paths.
  • Calling next twice. Duplicates downstream side effects. The index guard above turns it into a clear error.
  • Hidden ordering dependencies. If requireRole silently assumes authenticate ran before it, a reordering breaks security. Make dependencies explicit, for example by having requireRole throw if ctx.user was never set by design rather than passing silently.
  • A shared mutable context. Handlers communicate by mutating ctx, which can become a grab bag. Keep the context typed, and prefer narrow, documented fields over arbitrary keys.
  • Hard-to-debug flow. When a chain is long, it is hard to see where a request stopped. Log the name of the handler that short-circuited, or build a trace into the composer.

Choosing Between First-Match and Pipeline Chains

Two variants hide under one name, and mixing them up causes confusion. In a first-match chain, such as the ticket example, exactly one handler is expected to take responsibility, and the rest are fallbacks ordered by specificity. The natural failure mode is nobody handling the request, so the last link should be a catch-all. In a pipeline chain, such as middleware, every handler participates, and the natural failure mode is a link that stops the flow by accident.

State which one you are building in the name and documentation of the composer. A function called firstMatch and one called pipeline communicate expectations that a generic chain does not. It also tells reviewers what a handler that returns early means: in the first it means success, in the second it usually means rejection.

When Not to Use It

If there are only two steps and they will never change, a plain function with two calls is clearer than any framework of handlers. If the steps must all run regardless of each other’s outcome and in a fixed order, a simple list of function calls expresses that better. If different inputs need different algorithms chosen by a known key, the Strategy pattern fits better, since nothing needs to be passed along. Use a chain when the set and order of steps varies, when steps are reused across pipelines, or when handlers genuinely decide whether to continue.

Key Takeaways

  • Chain of Responsibility decouples the sender of a request from the objects that process it by linking single-purpose handlers.
  • In TypeScript, the middleware form, functions taking a context and a next callback, is usually simpler than a class hierarchy.
  • Short-circuiting by not calling next() and wrapping logic around await next() are the two moves that cover most real needs.
  • Ordering is part of the contract; document it and guard against calling next() twice.
  • Each handler is a small unit that can be tested alone with a stub next.
  • Do not reach for a chain when a couple of fixed function calls will do.