Episode 9 — System Design / 9.5 — Behavioral Design Patterns

9.5.g Chain of Responsibility Pattern

Overview

The Chain of Responsibility Pattern lets you pass requests along a chain of handlers. Each handler decides either to process the request or to pass it to the next handler in the chain. The sender doesn't know which handler will process the request -- it just sends it into the chain.

This is the exact pattern behind Express.js middleware, DOM event propagation, and approval workflows.

+--------------------------------------------------------------+
|              CHAIN OF RESPONSIBILITY                          |
|                                                               |
|  Request --> [Handler A] --> [Handler B] --> [Handler C]      |
|                  |               |               |            |
|              Can handle?     Can handle?     Can handle?      |
|              Yes: process    No: pass        Yes: process     |
|              No: pass along  along           (or pass along)  |
|                                                               |
|  Each handler either:                                         |
|    1. Processes the request and stops                         |
|    2. Passes it to the next handler                           |
|    3. Processes AND passes (middleware style)                  |
+--------------------------------------------------------------+

The Problem: Rigid Handling Logic

// BAD: One class handling all validation/processing logic
class RequestHandler {
  handleRequest(request) {
    // Authentication
    if (!request.headers.authorization) {
      return { status: 401, body: 'Unauthorized' };
    }
    const token = request.headers.authorization.split(' ')[1];
    if (!this.verifyToken(token)) {
      return { status: 401, body: 'Invalid token' };
    }

    // Rate limiting
    if (this.isRateLimited(request.ip)) {
      return { status: 429, body: 'Too many requests' };
    }

    // Input validation
    if (!request.body || !request.body.email) {
      return { status: 400, body: 'Email required' };
    }

    // Logging
    console.log(`${request.method} ${request.path}`);

    // CORS
    // ... more logic

    // Finally, actual business logic
    return this.processBusinessLogic(request);
    // This one class does EVERYTHING
    // Can't reuse pieces independently
    // Can't reorder or add new checks easily
  }
}

Core Concepts

+------------------------------------------------------------------+
|                                                                    |
|  TWO STYLES OF CHAIN:                                             |
|                                                                    |
|  1. PURE CHAIN (first handler wins):                              |
|                                                                    |
|     Request --> [A] --X--> [B] --X--> [C] --> Response            |
|                 skip       skip      handles                      |
|                                                                    |
|  2. MIDDLEWARE CHAIN (all handlers run):                           |
|                                                                    |
|     Request --> [A] -------> [B] -------> [C] --> Response        |
|                 process &    process &    process &                |
|                 pass next    pass next    return                   |
|                                                                    |
|  Express.js uses style #2 (middleware):                           |
|    app.use(logger);    // runs, calls next()                      |
|    app.use(auth);      // runs, calls next() OR returns error     |
|    app.use(handler);   // processes the request                   |
|                                                                    |
+------------------------------------------------------------------+

Implementation 1: Express-Style Middleware Chain

// ============================================
// MIDDLEWARE HANDLER (Base)
// ============================================
class Middleware {
  constructor() {
    this.next = null;
  }

  setNext(middleware) {
    this.next = middleware;
    return middleware;  // Enable chaining
  }

  async handle(request, response) {
    if (this.next) {
      return this.next.handle(request, response);
    }
    return response;
  }
}

// ============================================
// CONCRETE MIDDLEWARE HANDLERS
// ============================================
class LoggerMiddleware extends Middleware {
  async handle(request, response) {
    const start = Date.now();
    console.log(`[Logger] --> ${request.method} ${request.path}`);

    // Process and pass to next
    const result = await super.handle(request, response);

    const duration = Date.now() - start;
    console.log(`[Logger] <-- ${request.method} ${request.path} [${result.status}] ${duration}ms`);

    return result;
  }
}

class CORSMiddleware extends Middleware {
  constructor(allowedOrigins = ['*']) {
    super();
    this.allowedOrigins = allowedOrigins;
  }

  async handle(request, response) {
    const origin = request.headers?.origin || '*';

    if (this.allowedOrigins.includes('*') || this.allowedOrigins.includes(origin)) {
      response.headers = {
        ...response.headers,
        'Access-Control-Allow-Origin': origin,
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization'
      };
      console.log(`[CORS] Allowed origin: ${origin}`);
    } else {
      console.log(`[CORS] Blocked origin: ${origin}`);
      return { status: 403, body: 'CORS: Origin not allowed', headers: {} };
    }

    return super.handle(request, response);
  }
}

class AuthMiddleware extends Middleware {
  constructor(secretKey = 'secret') {
    super();
    this.secretKey = secretKey;
    this.publicPaths = ['/health', '/login', '/register'];
  }

  async handle(request, response) {
    // Skip auth for public paths
    if (this.publicPaths.includes(request.path)) {
      console.log(`[Auth] Public path: ${request.path} (skipping auth)`);
      return super.handle(request, response);
    }

    const authHeader = request.headers?.authorization;
    if (!authHeader) {
      console.log('[Auth] No authorization header');
      return { status: 401, body: 'Authorization header required', headers: {} };
    }

    const token = authHeader.replace('Bearer ', '');
    try {
      // Simulate token verification
      const user = this._verifyToken(token);
      request.user = user;
      console.log(`[Auth] Authenticated: ${user.name} (role: ${user.role})`);
      return super.handle(request, response);
    } catch (error) {
      console.log(`[Auth] Invalid token: ${error.message}`);
      return { status: 401, body: 'Invalid token', headers: {} };
    }
  }

  _verifyToken(token) {
    // Simplified token verification
    if (token === 'valid-admin-token') {
      return { id: 1, name: 'Admin', role: 'admin' };
    }
    if (token === 'valid-user-token') {
      return { id: 2, name: 'User', role: 'user' };
    }
    throw new Error('Token verification failed');
  }
}

class RateLimitMiddleware extends Middleware {
  constructor(maxRequests = 100, windowMs = 60000) {
    super();
    this.maxRequests = maxRequests;
    this.windowMs = windowMs;
    this.requests = new Map(); // ip -> { count, resetAt }
  }

  async handle(request, response) {
    const ip = request.ip || 'unknown';
    const now = Date.now();

    let record = this.requests.get(ip);
    if (!record || now > record.resetAt) {
      record = { count: 0, resetAt: now + this.windowMs };
      this.requests.set(ip, record);
    }

    record.count++;

    response.headers = {
      ...response.headers,
      'X-RateLimit-Limit': this.maxRequests,
      'X-RateLimit-Remaining': Math.max(0, this.maxRequests - record.count),
      'X-RateLimit-Reset': record.resetAt
    };

    if (record.count > this.maxRequests) {
      console.log(`[RateLimit] ${ip} exceeded limit (${record.count}/${this.maxRequests})`);
      return {
        status: 429,
        body: 'Too many requests. Please slow down.',
        headers: response.headers
      };
    }

    console.log(`[RateLimit] ${ip}: ${record.count}/${this.maxRequests}`);
    return super.handle(request, response);
  }
}

class ValidationMiddleware extends Middleware {
  constructor(rules = {}) {
    super();
    this.rules = rules;
  }

  async handle(request, response) {
    if (['POST', 'PUT', 'PATCH'].includes(request.method)) {
      const errors = this._validate(request.body);
      if (errors.length > 0) {
        console.log(`[Validation] Failed: ${errors.join(', ')}`);
        return { status: 400, body: { errors }, headers: {} };
      }
      console.log('[Validation] Passed');
    } else {
      console.log('[Validation] Skipped (GET request)');
    }

    return super.handle(request, response);
  }

  _validate(body) {
    const errors = [];
    for (const [field, rules] of Object.entries(this.rules)) {
      if (rules.required && (!body || !body[field])) {
        errors.push(`${field} is required`);
      }
      if (rules.type && body?.[field] && typeof body[field] !== rules.type) {
        errors.push(`${field} must be of type ${rules.type}`);
      }
      if (rules.minLength && body?.[field] && body[field].length < rules.minLength) {
        errors.push(`${field} must be at least ${rules.minLength} characters`);
      }
    }
    return errors;
  }
}

// ============================================
// PIPELINE BUILDER
// ============================================
class MiddlewarePipeline {
  constructor() {
    this.middlewares = [];
  }

  use(middleware) {
    this.middlewares.push(middleware);
    return this;
  }

  build() {
    // Chain all middleware together
    for (let i = 0; i < this.middlewares.length - 1; i++) {
      this.middlewares[i].setNext(this.middlewares[i + 1]);
    }
    return this.middlewares[0];
  }

  async execute(request) {
    if (this.middlewares.length === 0) {
      throw new Error('No middleware registered');
    }

    const chain = this.build();
    const response = { status: 200, body: null, headers: {} };
    return chain.handle(request, response);
  }
}

// ============================================
// USAGE
// ============================================
const pipeline = new MiddlewarePipeline();

pipeline
  .use(new LoggerMiddleware())
  .use(new CORSMiddleware(['http://localhost:3000', 'https://myapp.com']))
  .use(new RateLimitMiddleware(5, 60000))
  .use(new AuthMiddleware())
  .use(new ValidationMiddleware({
    email: { required: true, type: 'string' },
    name: { required: true, minLength: 2 }
  }));

// Test: Valid authenticated request
async function testPipeline() {
  console.log('\n=== Test 1: Valid Request ===');
  const result1 = await pipeline.execute({
    method: 'POST',
    path: '/api/users',
    ip: '192.168.1.1',
    headers: {
      authorization: 'Bearer valid-admin-token',
      origin: 'http://localhost:3000'
    },
    body: { email: 'test@example.com', name: 'Test User' }
  });
  console.log('Result:', result1);

  console.log('\n=== Test 2: Missing Auth ===');
  const result2 = await pipeline.execute({
    method: 'GET',
    path: '/api/users',
    ip: '192.168.1.2',
    headers: { origin: 'http://localhost:3000' },
    body: null
  });
  console.log('Result:', result2);

  console.log('\n=== Test 3: Public Path ===');
  const result3 = await pipeline.execute({
    method: 'GET',
    path: '/health',
    ip: '192.168.1.1',
    headers: { origin: 'http://localhost:3000' },
    body: null
  });
  console.log('Result:', result3);
}

// testPipeline();

Implementation 2: Approval Workflow

// ============================================
// APPROVAL CHAIN
// ============================================
class Approver {
  constructor(name, limit) {
    this.name = name;
    this.limit = limit;  // Max amount this person can approve
    this.nextApprover = null;
  }

  setNext(approver) {
    this.nextApprover = approver;
    return approver;
  }

  approve(request) {
    if (request.amount <= this.limit) {
      console.log(
        `[${this.name}] APPROVED: "${request.description}" ` +
        `for $${request.amount.toFixed(2)} ` +
        `(limit: $${this.limit.toFixed(2)})`
      );
      return {
        approved: true,
        approvedBy: this.name,
        amount: request.amount,
        level: this.getLevel()
      };
    }

    if (this.nextApprover) {
      console.log(
        `[${this.name}] Cannot approve $${request.amount.toFixed(2)} ` +
        `(limit: $${this.limit.toFixed(2)}). Escalating...`
      );
      return this.nextApprover.approve(request);
    }

    console.log(
      `[${this.name}] REJECTED: $${request.amount.toFixed(2)} ` +
      `exceeds maximum approval authority`
    );
    return {
      approved: false,
      reason: 'Exceeds maximum approval authority',
      amount: request.amount
    };
  }

  getLevel() {
    return 'Unknown';
  }
}

class TeamLead extends Approver {
  constructor() { super('Team Lead', 1000); }
  getLevel() { return 'Team'; }
}

class Manager extends Approver {
  constructor() { super('Department Manager', 5000); }
  getLevel() { return 'Department'; }
}

class Director extends Approver {
  constructor() { super('Director', 25000); }
  getLevel() { return 'Division'; }
}

class VP extends Approver {
  constructor() { super('VP of Engineering', 100000); }
  getLevel() { return 'Executive'; }
}

class CEO extends Approver {
  constructor() { super('CEO', Infinity); }
  getLevel() { return 'CEO'; }

  approve(request) {
    // CEO always approves but logs extra info
    if (request.amount > 50000) {
      console.log(`[CEO] Large expense review: $${request.amount.toFixed(2)}`);
      console.log(`[CEO] Justification: ${request.description}`);
    }
    return super.approve(request);
  }
}

// ============================================
// BUILD THE CHAIN
// ============================================
function buildApprovalChain() {
  const teamLead = new TeamLead();
  const manager = new Manager();
  const director = new Director();
  const vp = new VP();
  const ceo = new CEO();

  // Chain: TeamLead -> Manager -> Director -> VP -> CEO
  teamLead
    .setNext(manager)
    .setNext(director)
    .setNext(vp)
    .setNext(ceo);

  return teamLead;  // Return the start of the chain
}

// ============================================
// USAGE
// ============================================
const approvalChain = buildApprovalChain();

console.log('=== Expense Approvals ===\n');

const expenses = [
  { description: 'Office supplies', amount: 250 },
  { description: 'Team dinner', amount: 800 },
  { description: 'Conference tickets', amount: 3500 },
  { description: 'Server hardware', amount: 15000 },
  { description: 'Office renovation', amount: 75000 },
  { description: 'Company acquisition', amount: 500000 }
];

for (const expense of expenses) {
  console.log(`\nRequest: "${expense.description}" - $${expense.amount}`);
  const result = approvalChain.approve(expense);
  console.log(`Result: ${result.approved ? 'APPROVED' : 'REJECTED'}`);
  if (result.approved) {
    console.log(`  Approved by: ${result.approvedBy} (${result.level} level)`);
  }
}

Implementation 3: Logging Chain

// ============================================
// LOGGING CHAIN (handlers process AND pass along)
// ============================================
class LogHandler {
  constructor(level) {
    this.level = level;
    this.next = null;
    this.entries = [];
  }

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

  log(level, message, context = {}) {
    const levels = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3, FATAL: 4 };
    const numericLevel = levels[level] || 0;
    const handlerLevel = levels[this.level] || 0;

    // Process if this handler's level matches or is lower
    if (numericLevel >= handlerLevel) {
      this._write(level, message, context);
    }

    // Always pass to next handler (each handler may write to different output)
    if (this.next) {
      this.next.log(level, message, context);
    }
  }

  _write(level, message, context) {
    throw new Error('Must implement _write()');
  }
}

class ConsoleLogHandler extends LogHandler {
  constructor(level = 'DEBUG') {
    super(level);
  }

  _write(level, message, context) {
    const timestamp = new Date().toISOString();
    const contextStr = Object.keys(context).length > 0
      ? ` ${JSON.stringify(context)}`
      : '';

    const colors = {
      DEBUG: '\x1b[36m',   // Cyan
      INFO: '\x1b[32m',    // Green
      WARN: '\x1b[33m',    // Yellow
      ERROR: '\x1b[31m',   // Red
      FATAL: '\x1b[41m'    // Red background
    };
    const reset = '\x1b[0m';
    const color = colors[level] || '';

    console.log(
      `${color}[${timestamp}] [${level}]${reset} ${message}${contextStr}`
    );
  }
}

class FileLogHandler extends LogHandler {
  constructor(level = 'INFO') {
    super(level);
    this.buffer = [];
  }

  _write(level, message, context) {
    const entry = {
      timestamp: new Date().toISOString(),
      level,
      message,
      ...context
    };
    this.buffer.push(JSON.stringify(entry));

    // Simulate periodic flush
    if (this.buffer.length >= 10) {
      this._flush();
    }
  }

  _flush() {
    console.log(`[FileHandler] Flushing ${this.buffer.length} entries to disk`);
    // In real app: fs.appendFileSync(this.filePath, this.buffer.join('\n'));
    this.buffer = [];
  }

  getBuffer() {
    return [...this.buffer];
  }
}

class AlertLogHandler extends LogHandler {
  constructor() {
    super('ERROR');  // Only handles ERROR and above
    this.alerts = [];
  }

  _write(level, message, context) {
    const alert = {
      level,
      message,
      context,
      alertedAt: new Date().toISOString()
    };
    this.alerts.push(alert);
    console.log(
      `[ALERT] Sending notification: ${level} - ${message}`
    );
    // In real app: send email, Slack message, PagerDuty, etc.
  }
}

class MetricsLogHandler extends LogHandler {
  constructor() {
    super('DEBUG');
    this.counts = { DEBUG: 0, INFO: 0, WARN: 0, ERROR: 0, FATAL: 0 };
  }

  _write(level, message, context) {
    this.counts[level] = (this.counts[level] || 0) + 1;
  }

  getMetrics() {
    return { ...this.counts, total: Object.values(this.counts).reduce((a, b) => a + b, 0) };
  }
}

// ============================================
// BUILD AND USE THE CHAIN
// ============================================
const consoleHandler = new ConsoleLogHandler('DEBUG');
const fileHandler = new FileLogHandler('INFO');
const alertHandler = new AlertLogHandler();
const metricsHandler = new MetricsLogHandler();

// Chain: Console -> File -> Alert -> Metrics
consoleHandler
  .setNext(fileHandler)
  .setNext(alertHandler)
  .setNext(metricsHandler);

// Use the logger
const logger = consoleHandler;

logger.log('DEBUG', 'Application starting', { pid: 1234 });
logger.log('INFO', 'Server listening on port 3000');
logger.log('INFO', 'Connected to database', { host: 'localhost' });
logger.log('WARN', 'Memory usage high', { usage: '85%' });
logger.log('ERROR', 'Failed to process payment', { orderId: 'ORD-001', error: 'timeout' });
logger.log('FATAL', 'Database connection lost', { host: 'db.example.com' });

console.log('\nMetrics:', metricsHandler.getMetrics());
// { DEBUG: 1, INFO: 2, WARN: 1, ERROR: 1, FATAL: 1, total: 6 }

Chain of Responsibility Flow

+------------------------------------------------------------------+
|                                                                    |
|  PURE CHAIN (find first handler):                                 |
|                                                                    |
|  Request: $3,500 expense                                          |
|                                                                    |
|  [Team Lead]  ---> [Manager]   ---> [Director]                    |
|   limit: $1K       limit: $5K       limit: $25K                   |
|   Skip!            APPROVE!         (never reached)               |
|                                                                    |
|  MIDDLEWARE CHAIN (all handlers process):                          |
|                                                                    |
|  Request: POST /api/users                                         |
|                                                                    |
|  [Logger]  --next--> [CORS]  --next--> [Auth]  --next--> [Handler]|
|   Logs req          Adds headers      Verifies token    Business  |
|   Logs resp         (or rejects)      (or rejects)      logic    |
|                                                                    |
|  Key difference:                                                   |
|  - Pure chain: request handled by EXACTLY ONE handler             |
|  - Middleware: request passes through ALL handlers (unless one     |
|    short-circuits by not calling next)                             |
|                                                                    |
+------------------------------------------------------------------+

Real-World Examples

SystemChainHandlers
Express.jsMiddleware stackLogger, CORS, Auth, Router
DOM EventsBubbling/CapturingParent -> Child -> Target
HTTP ProxyProxy chainCache -> Auth -> Rate limit -> Origin
Exception Handlingtry/catch chainSpecific catch -> General catch -> Global handler
Corporate ApprovalManagement hierarchyManager -> Director -> VP -> CEO
Support TicketsEscalation chainBot -> L1 -> L2 -> L3 -> Engineering

Key Takeaways

  1. Chain of Responsibility decouples senders from receivers -- the request doesn't know which handler will process it
  2. Two styles: pure chain (first match handles) vs middleware chain (all handlers participate)
  3. Express.js middleware IS this pattern -- app.use() adds handlers, next() passes to the next one
  4. Handlers can be reordered, added, or removed without affecting other handlers or the sender
  5. Each handler has a single responsibility -- authentication, logging, rate limiting, etc.
  6. Watch for broken chains -- if no handler processes the request, you need a default/fallback

Explain-It Challenge

You're building a content moderation system for a social media platform. User-generated posts must pass through multiple checks: spam detection, profanity filter, image analysis, link safety check, and community guidelines review. Some checks are fast (profanity), some are slow (image analysis). Design this as a Chain of Responsibility. How would you handle: (a) some checks running in parallel for speed, (b) different confidence levels requiring human review, (c) different chains for different content types (text vs image vs video)?