Chain of Responsibility: Request Processing Pipeline

Chain of Responsibility solves a fundamental problem: how do you decouple the sender of a request from the code that handles it, especially when multiple objects might handle it?

Key Insights

  • Chain of Responsibility decouples request senders from receivers by passing requests through a sequence of handlers, each deciding whether to process or delegate—this is the foundation of middleware systems everywhere.
  • The pattern comes in two flavors: “pure” chains where exactly one handler processes the request, and “intercepting” chains where multiple handlers contribute to processing—choose based on whether you need exclusive handling or collaborative transformation.
  • The pattern shines in HTTP middleware, validation pipelines, and approval workflows, but watch out for debugging complexity and the risk of unhandled requests falling through the chain.

What is Chain of Responsibility?

Chain of Responsibility solves a fundamental problem: how do you decouple the sender of a request from the code that handles it, especially when multiple objects might handle it?

Think about a support ticket system. When you submit a ticket, it doesn’t go directly to a specific person. It flows through a chain: first-line support checks if it’s a known issue, then escalates to specialized teams, then to engineering if needed. Each handler in the chain either resolves the ticket or passes it along. The person submitting the ticket doesn’t need to know who will ultimately handle it.

This pattern appears everywhere in software: HTTP middleware stacks, DOM event bubbling, exception handling, approval workflows, and logging frameworks. Once you recognize it, you’ll see it constantly.

The core idea is simple: create a chain of handler objects. Each handler contains a reference to the next handler. When a request arrives, each handler either processes it, passes it to the next handler, or does both.

Core Pattern Structure

Let’s establish the foundational structure. The pattern requires three components: a handler interface, an abstract base handler that manages chain linking, and concrete handlers that implement specific logic.

// The handler interface defines the contract
interface Handler<T> {
  setNext(handler: Handler<T>): Handler<T>;
  handle(request: T): T | null;
}

// Abstract base handler manages chain mechanics
abstract class AbstractHandler<T> implements Handler<T> {
  private nextHandler: Handler<T> | null = null;

  setNext(handler: Handler<T>): Handler<T> {
    this.nextHandler = handler;
    // Return handler to allow chaining: a.setNext(b).setNext(c)
    return handler;
  }

  handle(request: T): T | null {
    if (this.nextHandler) {
      return this.nextHandler.handle(request);
    }
    return null;
  }
}

// Concrete handler example
class ValidationHandler extends AbstractHandler<Request> {
  handle(request: Request): Request | null {
    if (!request.isValid()) {
      console.log('Validation failed, stopping chain');
      return null;
    }
    console.log('Validation passed, continuing chain');
    return super.handle(request);
  }
}

The setNext method returns the handler to enable fluent chaining. Each concrete handler calls super.handle() to delegate to the next handler—or doesn’t call it to terminate the chain.

Building a Request Processing Pipeline

Let’s build something practical: an HTTP request processing pipeline with authentication, validation, rate limiting, and logging. This mirrors how frameworks like Express and Koa work internally.

interface HttpRequest {
  headers: Record<string, string>;
  body: unknown;
  userId?: string;
  timestamp: number;
}

interface HttpResponse {
  status: number;
  body: unknown;
}

interface RequestContext {
  request: HttpRequest;
  response?: HttpResponse;
  metadata: Record<string, unknown>;
}

abstract class Middleware {
  private next: Middleware | null = null;

  setNext(middleware: Middleware): Middleware {
    this.next = middleware;
    return middleware;
  }

  async handle(context: RequestContext): Promise<RequestContext> {
    if (this.next) {
      return this.next.handle(context);
    }
    return context;
  }
}

class AuthenticationMiddleware extends Middleware {
  async handle(context: RequestContext): Promise<RequestContext> {
    const token = context.request.headers['authorization'];
    
    if (!token) {
      context.response = { status: 401, body: { error: 'Unauthorized' } };
      return context; // Short-circuit: don't call next
    }

    // Simulate token validation
    const userId = this.validateToken(token);
    if (!userId) {
      context.response = { status: 401, body: { error: 'Invalid token' } };
      return context;
    }

    context.request.userId = userId;
    context.metadata.authenticated = true;
    return super.handle(context); // Continue chain
  }

  private validateToken(token: string): string | null {
    // Real implementation would verify JWT, check database, etc.
    return token.startsWith('Bearer ') ? 'user-123' : null;
  }
}

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

  async handle(context: RequestContext): Promise<RequestContext> {
    const userId = context.request.userId || 'anonymous';
    const now = Date.now();
    
    const userRequests = this.requests.get(userId) || [];
    const recentRequests = userRequests.filter(t => now - t < this.windowMs);
    
    if (recentRequests.length >= this.limit) {
      context.response = { status: 429, body: { error: 'Rate limit exceeded' } };
      return context;
    }

    recentRequests.push(now);
    this.requests.set(userId, recentRequests);
    
    return super.handle(context);
  }
}

class LoggingMiddleware extends Middleware {
  async handle(context: RequestContext): Promise<RequestContext> {
    const start = Date.now();
    console.log(`[${new Date().toISOString()}] Request started`);
    
    const result = await super.handle(context);
    
    const duration = Date.now() - start;
    console.log(`[${new Date().toISOString()}] Request completed in ${duration}ms`);
    
    return result;
  }
}

// Assembling the pipeline
const logging = new LoggingMiddleware();
const auth = new AuthenticationMiddleware();
const rateLimit = new RateLimitMiddleware();

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

// Usage
const context: RequestContext = {
  request: {
    headers: { 'authorization': 'Bearer valid-token' },
    body: { data: 'test' },
    timestamp: Date.now()
  },
  metadata: {}
};

const result = await logging.handle(context);

Notice how each middleware can short-circuit the chain by returning early without calling super.handle(). The logging middleware wraps the entire chain, measuring total processing time.

Variations: Pure vs. Intercepting Chains

The classic Chain of Responsibility pattern assumes exactly one handler processes each request—once a handler takes responsibility, the chain stops. But many real-world implementations use an “intercepting” variation where multiple handlers contribute.

Pure Chain (One Handler Processes):

class SupportHandler extends AbstractHandler<Ticket> {
  constructor(private expertise: string, private canHandle: (t: Ticket) => boolean) {
    super();
  }

  handle(ticket: Ticket): Resolution | null {
    if (this.canHandle(ticket)) {
      return this.resolve(ticket); // Handles it, chain stops
    }
    return super.handle(ticket); // Pass to next
  }

  private resolve(ticket: Ticket): Resolution {
    return { handler: this.expertise, resolved: true };
  }
}

Intercepting Chain (All Handlers Contribute):

class TransformMiddleware extends Middleware {
  async handle(context: RequestContext): Promise<RequestContext> {
    // Transform request BEFORE passing down
    context.request.body = this.sanitize(context.request.body);
    
    // Pass to next handler
    const result = await super.handle(context);
    
    // Transform response AFTER getting result back
    if (result.response) {
      result.response.body = this.addMetadata(result.response.body);
    }
    
    return result;
  }
}

Use pure chains when requests need exclusive handling (support tickets, command processing). Use intercepting chains when you need layered transformations (middleware stacks, data pipelines).

Real-World Applications

Express/Koa Middleware: Every app.use() call adds a handler to the chain. The next() function is the mechanism for passing control.

Servlet Filters: Java’s FilterChain.doFilter() implements this pattern for HTTP request/response processing in web applications.

DOM Event Bubbling: When you click a button, the event bubbles up through parent elements. Each element can handle the event or let it propagate.

Exception Handling: Try-catch blocks form a chain. If one catch block doesn’t handle an exception type, it propagates to the next enclosing try-catch.

Approval Workflows: Purchase requests under $100 go to managers, under $1000 to directors, above that to executives.

Benefits, Trade-offs, and Anti-patterns

Benefits:

  • Loose coupling: senders don’t know which handler processes their request
  • Flexible ordering: reorder, add, or remove handlers without changing others
  • Single responsibility: each handler focuses on one concern

Trade-offs:

  • No handling guarantee: requests might fall through without being processed
  • Debugging complexity: tracing request flow through multiple handlers is harder
  • Performance overhead: long chains add latency

Anti-patterns to Avoid:

  • Circular chains: Handler A points to B, B points to A. Infinite loop.
  • God handlers: One handler that does everything defeats the pattern’s purpose.
  • Implicit ordering dependencies: If handler B requires handler A to run first but nothing enforces this, you have a bug waiting to happen.

Implementation Tips

Handler Ordering: Put fast-failing handlers early. Authentication before expensive validation. Rate limiting before heavy processing.

Short-Circuiting: Make it explicit when and why handlers stop the chain. Return meaningful responses, not just null.

Error Handling: Decide whether errors should stop the chain or be collected. Consider a dedicated error-handling handler at the end.

Testing Individual Handlers:

describe('AuthenticationMiddleware', () => {
  it('should reject requests without tokens', async () => {
    const auth = new AuthenticationMiddleware();
    const mockNext = new MockMiddleware();
    auth.setNext(mockNext);

    const context: RequestContext = {
      request: { headers: {}, body: null, timestamp: Date.now() },
      metadata: {}
    };

    const result = await auth.handle(context);

    expect(result.response?.status).toBe(401);
    expect(mockNext.handleCalled).toBe(false); // Chain stopped
  });

  it('should continue chain with valid token', async () => {
    const auth = new AuthenticationMiddleware();
    const mockNext = new MockMiddleware();
    auth.setNext(mockNext);

    const context: RequestContext = {
      request: { 
        headers: { 'authorization': 'Bearer valid' }, 
        body: null, 
        timestamp: Date.now() 
      },
      metadata: {}
    };

    await auth.handle(context);

    expect(mockNext.handleCalled).toBe(true);
    expect(context.request.userId).toBe('user-123');
  });
});

class MockMiddleware extends Middleware {
  handleCalled = false;
  
  async handle(context: RequestContext): Promise<RequestContext> {
    this.handleCalled = true;
    return context;
  }
}

Test each handler in isolation by mocking the next handler. Verify both the handler’s direct behavior and whether it correctly continues or stops the chain.

Chain of Responsibility is one of those patterns that seems simple but enables sophisticated architectures. Master it, and you’ll design cleaner, more maintainable request processing systems.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.