StackPractices
intermediate By Mathias Paulenko

Chain of Responsibility for Request Processing Middleware

Pass requests along a chain of handlers where each handler decides whether to process the request or pass it to the next handler in the pipeline

The Chain of Responsibility pattern passes a request along a chain of handlers, each deciding whether to act on it or forward it. Applied to request processing, it becomes the middleware pipeline: authentication, rate limiting, validation, logging — each step is one link, and the request flows through them in order until it reaches the endpoint handler or a link short-circuits it.

When to Use This

  • An HTTP request must cross several concerns (auth, rate limits, validation, logging) and you want each one isolated and reorderable
  • The set of checks differs per route or environment — a public endpoint skips auth, a webhook skips schema validation — so the chain must be composed dynamically
  • Steps need the option to stop processing early: reject an invalid token, serve a cached response, bail out on a malformed body
  • You want to add or remove steps without touching the code that invokes the pipeline

Do not reach for a chain when the pipeline has one or two fixed steps, when a plain if/else reads clearer, or when steps must run in parallel — a chain is sequential by definition.

Problem

An HTTP request needs authentication, rate limiting, input validation, and logging. Hardcode that sequence inside the router and every route inherits a rigid script: skip one check and you get an inconsistency nobody catches until production. The chain turns each concern into a handler with a single responsibility that can be inserted, removed, or reordered without rewriting the pipeline.

Solution

// chain/Handler.ts
interface RequestContext {
  headers: Record<string, string>;
  body: unknown;
  path: string;
  method: string;
  user?: { id: string; roles: string[] };
}

type NextFunction = () => void;

abstract class MiddlewareHandler {
  protected next: MiddlewareHandler | null = null;

  setNext(handler: MiddlewareHandler): MiddlewareHandler {
    this.next = handler;
    return handler;
  }

  handle(req: RequestContext, next: NextFunction): void {
    if (this.canHandle(req)) {
      this.process(req, () => {
        if (this.next) {
          this.next.handle(req, next);
        } else {
          next();
        }
      });
    } else if (this.next) {
      this.next.handle(req, next);
    } else {
      next();
    }
  }

  protected abstract canHandle(req: RequestContext): boolean;
  protected abstract process(req: RequestContext, next: NextFunction): void;
}

// Concrete Handlers
class AuthMiddleware extends MiddlewareHandler {
  protected canHandle(): boolean {
    return true; // Always check auth
  }

  protected process(req: RequestContext, next: NextFunction): void {
    const token = req.headers['authorization']?.replace('Bearer ', '');

    if (!token) {
      throw new Error('Unauthorized');
    }

    // Verify token
    req.user = { id: 'user123', roles: ['user'] };
    next();
  }
}

class RateLimitMiddleware extends MiddlewareHandler {
  private requests = new Map<string, number[]>();
  private readonly windowMs = 60000;
  private readonly maxRequests = 100;

  protected canHandle(): boolean {
    return true;
  }

  protected process(req: RequestContext, next: NextFunction): void {
    const clientId = req.headers['x-client-id'] || req.user?.id || 'anonymous';
    const now = Date.now();
    const window = this.requests.get(clientId) || [];

    const recent = window.filter(t => now - t < this.windowMs);

    if (recent.length >= this.maxRequests) {
      throw new Error('Rate limit exceeded');
    }

    recent.push(now);
    this.requests.set(clientId, recent);
    next();
  }
}

class ValidationMiddleware extends MiddlewareHandler {
  protected canHandle(req: RequestContext): boolean {
    return req.method === 'POST' || req.method === 'PUT';
  }

  protected process(req: RequestContext, next: NextFunction): void {
    if (!req.body || typeof req.body !== 'object') {
      throw new Error('Invalid request body');
    }
    next();
  }
}

class LoggingMiddleware extends MiddlewareHandler {
  protected canHandle(): boolean {
    return true;
  }

  protected process(req: RequestContext, next: NextFunction): void {
    console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
    next();
  }
}

// Build chain
const auth = new AuthMiddleware();
const rateLimit = new RateLimitMiddleware();
const validation = new ValidationMiddleware();
const logging = new LoggingMiddleware();

auth.setNext(rateLimit).setNext(validation).setNext(logging);

// Usage
function handleRequest(req: RequestContext): void {
  auth.handle(req, () => {
    console.log('Request reached final handler');
  });
}

How It Works

Mermaid flowchart LR diagram
  1. MiddlewareHandler declares the contract: canHandle decides whether the link applies to this request, process does the work, setNext wires the chain.
  2. Concrete handlers (auth, rate limit, validation, logging) either run their logic and call next(), or skip straight to the following link.
  3. Any link can short-circuit — throw an error, write the response, or simply never call next() — and nothing downstream executes.

Order is the contract, and it stays implicit. auth.setNext(rateLimit).setNext(validation) means auth runs first; swap two links and you might validate a body before authenticating the caller. Document the intended order or build the chain in one obvious place.

Variation: Express-Style compose()

// Express-style with functions instead of classes
type Middleware = (req: RequestContext, next: NextFunction) => void;

function compose(middlewares: Middleware[]): Middleware {
  return (req, finalNext) => {
    let index = -1;

    function dispatch(i: number): void {
      if (i <= index) throw new Error('next() called multiple times');
      index = i;

      const fn = i < middlewares.length ? middlewares[i] : finalNext;
      if (!fn) return;

      fn(req, () => dispatch(i + 1));
    }

    dispatch(0);
  };
}

const pipeline = compose([
  (req, next) => { console.log('Auth'); next(); },
  (req, next) => { console.log('Rate limit'); next(); },
  (req, next) => { console.log('Log'); next(); },
]);

This compose is essentially what Koa’s koa-compose does: dispatch(i) runs middleware i, and each next() resumes the following index. The i <= index guard throws when a middleware calls next() twice — the classic “double next” bug.

Variation: Servlet Filter (Java)

The same shape ships in Java’s Servlet API — FilterChain.doFilter() is literally the pattern’s next(), and skipping it short-circuits the chain:

public class AuthFilter implements Filter {
  public void doFilter(ServletRequest req, ServletResponse res,
                       FilterChain chain) throws IOException, ServletException {
    HttpServletRequest http = (HttpServletRequest) req;
    String token = http.getHeader("Authorization");

    if (token == null || !token.startsWith("Bearer ")) {
      ((HttpServletResponse) res).sendError(401); // short-circuit
      return;
    }

    req.setAttribute("user", token.substring(7)); // request-scoped state
    chain.doFilter(req, res);                     // next()
  }
}

Filters are declared in web.xml or with @WebFilter and ordered by the container — the order contract lives in configuration, not in the code, exactly like the class chain above.

Middleware in Real Frameworks

The pattern already sits inside most HTTP stacks; you usually configure a chain rather than build one:

  • Express / Connect: app.use(fn) appends to a linear chain; next(err) jumps straight to the error-handling middleware at the end.
  • Koa: the “onion” model — koa-compose lets code run before and after await next(), so one middleware can time the whole downstream chain.
  • ASP.NET Core: Use may call next, Run terminates the pipeline, and Map branches it by path — chain of responsibility plus routing.
  • Django: process_request / process_response hooks around the view, declared once in the MIDDLEWARE setting.
  • Java Servlets: FilterChain.doFilter() is literally the pattern’s next().

The differences matter: Express forwards errors to a dedicated error channel instead of throwing, Koa awaits next() so the chain unwinds back through the same functions, and ASP.NET exposes branching instead of a single line.

Consequences and Trade-offs

  • Decoupled steps, coupled order. Each link is independent, but the chain’s behavior depends on the sequence — auth before rate limiting counts only authenticated traffic; logging first captures rejected requests too. Pick the order deliberately.
  • A long chain is harder to debug than a function. A request that “disappears” usually means some middleware forgot next(). Correlation IDs and per-link timing middleware pay for themselves fast.
  • Failure semantics differ per link. An exception mid-chain leaves the request in an undefined state; decide whether a failed rate limiter should fail closed (reject) or fail open (log and continue).
  • Sequential cost adds up. Ten middleware at 1 ms each is 10 ms of tail latency before the handler runs. Keep hot-path links cheap and push heavy work behind short-circuits or caches.
  • Easy to unit-test, weak end-to-end. Each link tests fine in isolation, but ordering bugs only appear when the real chain runs — keep at least one integration test over the full pipeline.

Production Considerations

  • Keep handlers stateless or store state on the request/context object. The example’s in-memory Map rate limiter works on one process but splits the budget across replicas — move it to Redis when you scale horizontally.
  • Mixed sync/async middleware is the top cause of hung requests: an async handler that never calls next() and never throws leaves the socket open until a timeout middleware, or the client, gives up.
  • Fail loudly for security links (auth, signature verification), fail soft for observability links (metrics, logging) — a dead metrics endpoint should never 500 your API.
  • Instrument the chain itself: emit per-middleware timing so you can see which link owns the latency.

Common Mistakes

  • Forgetting next() in an async handler, so the request hangs instead of erroring
  • Building a circular chain that loops forever between two handlers
  • Storing per-request state in shared middleware fields that leak across concurrent requests
  • Swallowing errors inside a link instead of forwarding them to the error handler at the end
  • Running expensive work (DB lookups, external calls) early in the chain with no cache or short-circuit
  • Ending the chain with no default handler, so unmatched requests silently fall through
  • Letting one middleware absorb several concerns — auth and validation and logging in a single link
  • Placing validation before authentication and leaking which endpoints exist to anonymous callers
  • Using a chain where a two-line if would do

Best Practices

  1. One concern per link. If a middleware does two things, split it — you want to reorder, skip, and test each concern independently.
  2. Always resolve the branch. Every path through a middleware must call next(), write a response, or forward an error. A “does this handler always end the request” review is worth a lint rule.
  3. Put cheap rejections first. Signature and size checks belong before auth and DB lookups — reject garbage before it costs you a query.
  4. Treat order as configuration. Build the chain in one file, comment why the order matters, and avoid scattered use() calls across the codebase.
  5. Propagate errors to the end of the chain. next(err) — or throwing in async code — should land in a single error middleware that maps errors to responses, never in ad-hoc error formats per link.
  6. Scope state to the request. Anything per-request lives on the context object, not on this.
  7. Time every link in production. A middleware that measures downstream latency is itself a good first link.

See Also

Frequently Asked Questions

How is this different from the Decorator pattern?

Decorator wraps an object to add behavior and every layer runs; a chain of handlers passes the request along until one link handles or short-circuits it — links can skip themselves entirely.

Is Express middleware a Chain of Responsibility?

Effectively, yes: app.use() registers handlers that call next() to forward the request. Express adds a second channel — next(err) jumps straight to error-handling middleware — which the plain GoF chain doesn't model.

Can I add or reorder middleware at runtime?

Yes, if you build the chain through a registry or a use()-style API instead of hardcoded setNext calls. Most apps only need startup-time configuration; dynamic reordering is rare and makes ordering bugs harder to trace.

What happens if a middleware forgets to call next()?

The request hangs. Nothing downstream runs, no response is written, and the client waits until a timeout fires. It's the most common middleware bug — put a request-timeout middleware near the front so it surfaces as an error instead of a stalled socket.

Should middleware be async or sync?

Match your framework. In async code always await downstream work before returning — a fire-and-forget next() lets the request finish before validation or logging completes. Mixing sync next() with async handlers is where hung requests and unhandled rejections come from.

How do I share data between middleware?

Attach it to the request/context object (req.user, req.locals, a context map), never to the middleware instance — one instance serves every concurrent request. Downstream links can read it but shouldn't depend on a specific upstream link having produced it.

Should a failed middleware reject the request or continue?

Depends on the link's job. Security links fail closed — auth, signature checks, CSRF. Observability links fail open: if metrics or logging breaks, record it and call next() — a dead dashboard shouldn't take your API down.

How do I decide middleware ordering?

Cheap rejections first (request size, signature), then identity (auth), then expensive checks (rate limits, validation), then enrichment, then the endpoint. Invert it and you pay full price for requests you would have rejected in microseconds.

How do I test a middleware chain?

Unit-test each link with a stub next() and assert whether it called it, wrote a response, or forwarded an error. Keep one integration test that runs the whole chain — order and interaction bugs only appear when real links run together.

When is this pattern overkill?

With one or two fixed steps, a plain if or a function call is clearer. The chain earns its complexity when steps are many, conditional, dynamically configured, or need independent short-circuit behavior.