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
| System | Chain | Handlers |
|---|---|---|
| Express.js | Middleware stack | Logger, CORS, Auth, Router |
| DOM Events | Bubbling/Capturing | Parent -> Child -> Target |
| HTTP Proxy | Proxy chain | Cache -> Auth -> Rate limit -> Origin |
| Exception Handling | try/catch chain | Specific catch -> General catch -> Global handler |
| Corporate Approval | Management hierarchy | Manager -> Director -> VP -> CEO |
| Support Tickets | Escalation chain | Bot -> L1 -> L2 -> L3 -> Engineering |
Key Takeaways
- Chain of Responsibility decouples senders from receivers -- the request doesn't know which handler will process it
- Two styles: pure chain (first match handles) vs middleware chain (all handlers participate)
- Express.js middleware IS this pattern --
app.use()adds handlers,next()passes to the next one - Handlers can be reordered, added, or removed without affecting other handlers or the sender
- Each handler has a single responsibility -- authentication, logging, rate limiting, etc.
- 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)?