Introduction
Request handling shouldn't be a monolithic block of if-else statements. When you need to pass work through multiple stages—each deciding whether to act, delegate, or stop—you're looking at the chain of responsibility pattern in action. Chain of responsibility: real-world lessons and examples show us how to build extensible, testable pipelines that grow with your product without spiralling into maintenance nightmares.
We've all inherited codebases where a single endpoint handler tries to do everything: validation, enrichment, checking permissions, logging, notifications. The chain of responsibility pattern breaks that down by letting each handler own one concern and decide what happens next. In this article, we'll walk through real scenarios—lead qualification, payment processing, content moderation—and show you when and how to apply this pattern effectively.
Middleware is this pattern — Each handler decides to act, pass on or stop
If you've built Express.js or NestJS applications, you've already used the chain of responsibility pattern. Middleware sits at the heart of how modern frameworks route and process requests. Each middleware function can inspect the request, modify it, pass it to the next handler, or stop the chain entirely.
The pattern gives us three clear choices at each step:
- Act and continue: Handle your concern, maybe transform the data, then call next()
- Act and stop: Handle your concern and prevent further handlers from running
- Pass through: Do nothing and immediately delegate to the next handler
This decoupling is powerful. Each handler is independent; it doesn't know or care about handlers before or after it. Want to add logging? Insert a logging handler. Need rate limiting? Drop it in at the start of the chain. The existing handlers don't change.
type Handler<T> = (data: T, next: () => Promise<void>) => Promise<void>;
// Classic middleware pattern
const createPipeline = <T>(handlers: Handler<T>[]) => {
return async (data: T) => {
let index = -1;
const dispatch = async (i: number): Promise<void> => {
if (i <= index) return;
index = i;
const handler = handlers[i];
if (!handler) return;
await handler(data, () => dispatch(i + 1));
};
await dispatch(0);
};
};
This is the essence of Express middleware. Each handler receives the context (request/response or domain object), a next function, and chooses what to do. If it calls next(), control passes to the handler at the next index. If it doesn't, the chain stops.
Example: lead validation — Duplicate check → spam check → enrichment → assignment
Let's ground this in a real scenario: a SaaS platform that ingests sales leads. A lead arrives via form submission, webhook, or API. Before it reaches the sales team, it must pass through several validation and enrichment stages. Each stage is independent, has clear rules, and can reject or modify the lead.
Our pipeline:
- Deduplication: Check if this email or phone exists and is recent; reject if duplicate
- Spam detection: Flag known spam emails, disposable domains, honeypots; mark as spam if caught
- Enrichment: Call a third-party API to fill in company name, industry, location
- Assignment: Assign the lead to a sales rep based on territory and capacity
interface Lead {
id: string;
email: string;
phone?: string;
name: string;
company?: string;
industry?: string;
location?: string;
assignedTo?: string;
status: 'new' | 'duplicate' | 'spam' | 'enriched' | 'assigned';
metadata?: Record<string, any>;
}
type LeadHandler = (
lead: Lead,
next: () => Promise<void>
) => Promise<void>;
// Handler 1: Deduplicate
const deduplicateHandler: LeadHandler = async (lead, next) => {
const recent = await db.leads.findOne({
email: lead.email,
createdAt: { $gt: Date.now() - 30 * 24 * 60 * 60 * 1000 },
});
if (recent) {
lead.status = 'duplicate';
console.log(`Lead ${lead.id} is a duplicate of ${recent.id}`);
return; // Stop the chain
}
await next();
};
// Handler 2: Check spam
const spamCheckHandler: LeadHandler = async (lead, next) => {
const isSpam = await spamService.check(lead.email);
if (isSpam) {
lead.status = 'spam';
console.log(`Lead ${lead.id} flagged as spam`);
return; // Stop the chain
}
await next();
};
// Handler 3: Enrich with third-party data
const enrichmentHandler: LeadHandler = async (lead, next) => {
try {
const enriched = await enrichmentService.lookup(lead.email);
lead.company = enriched.company;
lead.industry = enriched.industry;
lead.location = enriched.location;
console.log(`Enriched lead ${lead.id}`);
} catch (err) {
console.warn(`Enrichment failed for ${lead.id}, continuing anyway`, err);
}
await next();
};
// Handler 4: Assign to a sales rep
const assignmentHandler: LeadHandler = async (lead, next) => {
const rep = await assignmentService.getAvailableRep(lead.location);
if (rep) {
lead.assignedTo = rep.id;
lead.status = 'assigned';
console.log(`Lead ${lead.id} assigned to ${rep.name}`);
} else {
lead.status = 'enriched';
console.log(`No rep available for lead ${lead.id}, queuing`);
}
await next();
};
// Build and execute the pipeline
const leadPipeline = createPipeline<Lead>([
deduplicateHandler,
spamCheckHandler,
enrichmentHandler,
assignmentHandler,
]);
// Usage
const lead: Lead = {
id: 'lead-001',
email: 'contact@example.com',
name: 'Alice Chen',
status: 'new',
};
await leadPipeline(lead);
// Now lead.status, lead.company, lead.assignedTo, etc. are populated
// Save it
await db.leads.insertOne(lead);
Notice how each handler has a single responsibility. The deduplication handler doesn't know about enrichment. The enrichment handler doesn't care about spam. If enrichment fails, we log it and continue—the chain doesn't break. Each handler can reject the lead (by not calling next()), transform it, or simply observe it.
Easy to extend — Add a handler without touching the others
Three months later, the marketing team asks: "Can we tag leads that match our ideal customer profile?" You don't rewrite the deduplication, spam, or enrichment handlers. You add a new handler and insert it into the pipeline.
// New handler: ICP (Ideal Customer Profile) matching
const icpMatchHandler: LeadHandler = async (lead, next) => {
if (!lead.company || !lead.industry) {
// Can't match without enrichment data
await next();
return;
}
const isIcp = await icpService.matches({
industry: lead.industry,
companySize: lead.metadata?.companySize,
location: lead.location,
});
if (isIcp) {
lead.metadata = lead.metadata || {};
lead.metadata.icpMatch = true;
console.log(`Lead ${lead.id} matches ICP`);
}
await next();
};
// Updated pipeline: insert ICP check after enrichment, before assignment
const leadPipeline = createPipeline<Lead>([
deduplicateHandler,
spamCheckHandler,
enrichmentHandler,
icpMatchHandler, // New!
assignmentHandler,
]);
The old handlers are untouched. The ICP handler doesn't know about deduplication or assignment. It only cares that enrichment has run before it (because it relies on company and industry data). This is the pattern's superpower: extensibility without fragility.
You can also make handlers conditional. Add a feature flag to enable/disable handlers at runtime:
const buildLeadPipeline = () => {
const handlers: LeadHandler[] = [
deduplicateHandler,
spamCheckHandler,
enrichmentHandler,
];
if (featureFlags.isEnabled('icp-matching')) {
handlers.push(icpMatchHandler);
}
handlers.push(assignmentHandler);
return createPipeline(handlers);
};
Order matters — Document and test the chain's order
The order of handlers is not arbitrary. Enrichment should come after spam and deduplication, not before. Assignment should come after enrichment, so the sales rep knows the lead's industry. Break this order, and your logic breaks.
Document the pipeline's order and the reason for it:
/**
* Lead processing pipeline
*
* Order rationale:
* 1. Deduplicate: Reject old duplicates early, before expensive API calls
* 2. Spam: Reject known spam before enrichment (cost savings)
* 3. Enrich: Fetch external data (company, industry) for later handlers
* 4. ICP Match: Requires enriched data (industry, company size)
* 5. Assign: Assign to rep based on territory, ICP status, and capacity
*
* If you reorder, update this doc and the tests.
*/
const leadPipeline = createPipeline<Lead>([
deduplicateHandler, // 1
spamCheckHandler, // 2
enrichmentHandler, // 3
icpMatchHandler, // 4
assignmentHandler, // 5
]);
Test the pipeline end-to-end and test individual handlers in isolation. A good test suite catches order dependencies early:
describe('Lead Pipeline', () => {
it('should reject duplicate leads without enrichment', async () => {
const existing = await db.leads.insertOne({
email: 'alice@example.com',
createdAt: new Date(),
});
const lead: Lead = {
id: 'lead-002',
email: 'alice@example.com',
name: 'Alice Chen',
status: 'new',
};
await leadPipeline(lead);
expect(lead.status).toBe('duplicate');
expect(lead.company).toBeUndefined(); // Should not enrich duplicates
});
it('should enrich non-spam leads', async () => {
const lead: Lead = {
id: 'lead-003',
email: 'bob@techcorp.com',
name: 'Bob Smith',
status: 'new',
};
await leadPipeline(lead);
expect(lead.status).toBe('assigned'); // Made it through
expect(lead.company).toBeTruthy(); // Was enriched
});
it('should not assign leads if no rep is available', async () => {
assignmentService.getAvailableRep = jest.fn().mockResolvedValue(null);
const lead: Lead = {
id: 'lead-004',
email: 'charlie@startup.io',
name: 'Charlie Davis',
status: 'new',
};
await leadPipeline(lead);
expect(lead.status).toBe('enriched'); // Not assigned
expect(lead.assignedTo).toBeUndefined();
});
});
Common mistakes
Not documenting order dependencies. You add a handler to the end of the pipeline without realizing it depends on data from an earlier handler. Six months later, someone reorders handlers for a "performance win" and breaks the logic silently.
Handlers with side effects that assume a specific prior state. If a handler assumes the lead has already been enriched but enrichment is optional (or fails), the handler behaves unexpectedly. Make dependencies explicit; check state before relying on it.
Swallowing errors in catch blocks. An enrichment API times out. You log a warning and continue, but now your lead is missing critical data. Should the lead be rejected? Should you retry? Be intentional about error handling in each handler.
Building chains that are too long. A pipeline with 15 handlers is hard to reason about. If your chain grows beyond 5–7 handlers, consider breaking it into sub-pipelines or using a state machine instead.
Testing handlers only in isolation. A handler works fine on its own, but in the full pipeline, the order matters. Always test end-to-end. Test failure modes: what happens if handler 3 fails? Does handler 4 still run? Should it?
Key takeaways
- Chain of responsibility decouples handlers so each owns one concern and decides whether to continue, transform, or stop the chain.
- It's the pattern behind middleware in Express, NestJS, and similar frameworks; if you've used
app.use(), you've used this pattern. - Order matters and must be documented. The enrichment handler should run before assignment; deduplicate before enrichment to save API calls.
- Extend pipelines by adding new handlers without touching existing ones; use feature flags to enable/disable handlers at runtime.
- Test handlers in isolation and test the full pipeline end-to-end, including failure scenarios and order dependencies.
- Keep pipelines focused and relatively short; if you're building a 15-handler chain, consider breaking it into sub-pipelines or modeling state explicitly.
- Be intentional about error handling in each handler: should an error reject the entire request, trigger a retry, or log and continue?






Comments
Loading comments…