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 requests along a chain of handlers. Each handler decides either to process the request or to pass it to the next handler on the chain. This pattern decouples senders from receivers, allowing multiple objects to handle a request without the sender knowing which object will finally process it.
When to Use This
- More than one object may handle a request and the handler is not known in advance
- You want to issue a request to one of several objects without specifying the receiver explicitly
- The set of objects that can handle a request should be specified dynamically
Problem
An HTTP request needs to pass through authentication, rate limiting, request validation, and logging. Hardcoding this sequence in the router makes the pipeline rigid and hard to extend.
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
- Handler declares the interface for handling requests and accessing the next handler
- Concrete Handler processes requests it is responsible for or forwards them
- Client initiates the request to a handler in the chain
Variation: Express-Style Middleware
// 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(); },
]);
Production Considerations
- Ensure handlers call
next()to avoid stalling the pipeline - Consider short-circuiting (not calling
next()) for caching or early rejection - Keep middleware stateless or scoped to the request to prevent leaks
Common Mistakes
- Creating circular chains that cause infinite loops
- Not calling
next()in async handlers, causing requests to hang - Storing mutable state in handlers shared across concurrent requests
- Forgetting to handle errors in middleware, causing unhandled promise rejections
- Placing expensive operations early in the chain without caching
- Not providing a default handler at the end of the chain
- Mixing concerns within a single middleware instead of keeping them focused
- Not documenting middleware order and dependencies
- Failing to validate request data before processing
- Using the chain pattern when a simple conditional would suffice
Best Practices
-
Keep middleware single-responsibility. Each middleware should handle one specific concern (auth, validation, logging, etc.) to maintain clarity and testability.
-
Always call next() or explicitly short-circuit. Never leave middleware hanging without calling next() or sending a response.
-
Handle errors gracefully. Each middleware should catch and handle its own errors, or wrap them appropriately to prevent chain failure.
-
Keep middleware stateless. Avoid storing mutable state in middleware that is shared across requests. Use request-scoped state instead.
-
Document middleware order. Clearly document the expected order of middleware and any dependencies between them.
-
Use async/await for async operations. Always use async/await patterns for async middleware to avoid callback hell and ensure proper error handling.
-
Provide a default handler. Always include a catch-all handler at the end of the chain to handle requests that fall through.
-
Add logging and monitoring. Include logging middleware to trace request flow through the chain and identify bottlenecks or failures.
-
Avoid circular references. Ensure the chain structure is acyclic to prevent infinite loops during request processing.
-
Test middleware in isolation. Write unit tests for each middleware independently, then integration tests for the complete chain.
Frequently Asked Questions
How is this different from Decorator?
Decorator adds responsibilities dynamically but all decorators process the request. Chain of Responsibility passes requests until one handles it.
Can I add handlers at runtime?
Yes. This is the primary advantage — middleware can be registered dynamically based on routes or configuration.
How do I handle async operations in middleware?
Use async/await patterns for async middleware. Always await async operations before calling next() to ensure proper execution order and error handling.
Can middleware modify the request before passing it along?
Yes. Middleware can transform, enrich, or validate the request before forwarding it. This is common in middleware pipelines for adding metadata or sanitizing data.
How do I implement request timeout in middleware?
Add timeout middleware that tracks request duration and short-circuits if processing exceeds a threshold. This prevents slow handlers from blocking the pipeline indefinitely.
Should middleware be stateless?
Ideally yes. Stateless middleware is easier to test and reuse. If state is necessary, ensure it's scoped to the request (stored in the request context) rather than scoped to the middleware to avoid cross-request contamination.
How do I implement circuit breaking in middleware?
Add circuit breaker middleware that tracks failure rates and short-circuits requests when a downstream service is failing. This prevents cascading failures and improves system resilience.
Can I use this pattern for validation pipelines?
Yes. Validation chains where each middleware verifies a different aspect (format, business rules, security constraints) are a common use case. Results can be aggregated to provide detailed validation feedback.
How do I handle priority in middleware execution?
Implement middleware ordering based on priority where higher priority middleware executes first. This is useful for ensuring critical checks (auth, security) run before less critical operations.
Should I use dependency injection with middleware?
Yes. Middleware often requires dependencies (database connections, external services, configuration). Use dependency injection to provide these dependencies, making middleware testable and flexible.
Related Resources
Decorator Pattern for HTTP Request Pipelines
Use the Decorator pattern to compose cross-cutting concerns like logging, metrics, and retries into HTTP request pipelines without modifying core logic
PatternAbstract Factory for Cross-Platform UI Component Families
Create families of related objects without specifying concrete classes, enabling platform-specific implementations that share a common interface
PatternInterpreter Pattern for Domain-Specific Expression Languages
Build a language interpreter that evaluates expressions and rules by representing grammar as composable objects, useful for formulas, queries, and business rules
PatternVisitor Pattern for Extensible Operations on Object
Separate algorithms from the objects they operate on, allowing new operations to be added without modifying existing element classes