Learn how to write cleaner, more flexible code with the Chain of Responsibility pattern, perfect for handling requests, logging, and middleware in real-world projects.

Introduction
Ever written a giant if-else
A ladder that made you question your life choices? Maybe you had to handle different types of API errors, log them differently, and send custom responses depending on the case. Before you knew it, you had 50 lines of conditional logic that were a nightmare to extend.
That’s exactly the kind of problem the Chain of Responsibility pattern solves.
Instead of one bloated function, you build a pipeline of “handlers.” Each handler decides whether to process a request or pass it along. Think of it like customer support escalation: if Level 1 support can’t help, they pass the ticket to Level 2, then Level 3, until someone resolves it.
In this post, we’ll break down:
- ✅ What the Chain of Responsibility pattern is
- ✅ Why it’s useful (and when not to use it)
- ✅ Real-world scenarios (logging, middleware, event handling)
- ✅ Step-by-step implementation in JavaScript/TypeScript
- ✅ Practical gotchas & pro tips from real projects
By the end, you’ll know exactly how to spot when this pattern makes sense and how to implement it without overengineering.
What is the Chain of Responsibility Pattern?
The Chain of Responsibility (CoR) is a behavioral design pattern where a request is passed along a chain of handlers. Each handler can either:
- Handle the request
- Pass it to the next handler
This way:
- The sender doesn’t need to know who will handle the request.
- Handlers stay loosely coupled.
- You avoid spaghetti conditional logic.
👉 Analogy: Imagine reporting an issue at work. You first ask your team lead. If they can’t help, they escalate it to the manager. If the manager can’t fix it, it goes up to the director. Each level decides whether to act or escalate.
When Should You Use It?
The Chain of Responsibility pattern shines when you need flexibility in request handling:
- Error handling pipelines (e.g., different ways to log errors based on severity)
- Middleware in frameworks (Express.js, NestJS, and Redux middleware all use this idea)
- Event handling systems
- Validation chains (check input, sanitize, authorize, etc.)
⚠️ When NOT to use it:
- If you only have one or two conditions, don’t overcomplicate things. A simple
if-else
works fine. - If every handler must act, CoR may not be the right fit; consider an Observer pattern instead.
Real-World Examples Developers Already Use
1. Express.js Middleware
Every app.use()
call in Express adds a handler to the chain. Requests flow through the chain until one handler ends the cycle with res.send()
or next()
.
app.use((req, res, next) => {
console.log("Request logged");
next(); // passes to next handler
});
app.use((req, res, next) => {
if (!req.headers["auth-token"]) {
return res.status(401).send("Unauthorized");
}
next();
});
app.get("/data", (req, res) => {
res.send("Here's your data");
});
Here, Express implements CoR under the hood.
2. Logging Systems
Instead of stuffing everything into one logger, you chain them:
- Console logger → File logger → Remote server logger.
If one fails, the request moves to the next logger.
abstract class Logger {
protected next: Logger | null = null;
setNext(logger: Logger): Logger {
this.next = logger;
return logger;
}
log(message: string): void {
if (this.next) {
this.next.log(message);
}
}
}
class ConsoleLogger extends Logger {
log(message: string) {
console.log("Console:", message);
super.log(message);
}
}
class FileLogger extends Logger {
log(message: string) {
// Imagine writing to file
console.log("File:", message);
super.log(message);
}
}
const logger = new ConsoleLogger();
logger.setNext(new FileLogger());
logger.log("User signed in");
Step-by-Step: Building a Chain of Responsibility
Step 1: Define a Base Handler
Every handler should know how to forward requests.
interface Handler {
setNext(handler: Handler): Handler;
handle(request: string): void;
}
abstract class AbstractHandler implements Handler {
private nextHandler: Handler | null = null;
setNext(handler: Handler): Handler {
this.nextHandler = handler;
return handler;
}
handle(request: string): void {
if (this.nextHandler) {
this.nextHandler.handle(request);
}
}
}
Step 2: Create Concrete Handlers
class AuthHandler extends AbstractHandler {
handle(request: string) {
if (request === "auth") {
console.log("Handled by Auth");
} else {
super.handle(request);
}
}
}
class DataHandler extends AbstractHandler {
handle(request: string) {
if (request === "data") {
console.log("Handled by Data");
} else {
super.handle(request);
}
}
}
Step 3: Chain Them Together
const auth = new AuthHandler();
const data = new DataHandler();
auth.setNext(data);
auth.handle("auth"); // Handled by Auth
auth.handle("data"); // Passed to Data
auth.handle("other"); // Nothing happens
This is the heart of CoR: handlers pass along until one takes responsibility.
Benefits of Using Chain of Responsibility
- Cleaner code: No monster
if-else
blocks. - Reusability: Handlers can be reused in different chains.
- Flexibility: Add/remove handlers without breaking the system.
- Loose coupling: The sender doesn’t need to know the receiver.
Gotchas & Things to Watch Out For
- Silent failures: If no handler processes the request, nothing happens. Always define a fallback handler.
- Debugging can be tricky: When chains get long, tracing what happened is harder.
- Overengineering risk: If your logic is small, CoR may be overkill.
Practical Use Case: Input Validation Chain
Instead of cramming validation into one function, build a validation pipeline:
class RequiredFieldHandler extends AbstractHandler {
constructor(private field: string) { super(); }
handle(request: Record<string, any>) {
if (!request[this.field]) {
console.error(`Missing field: ${this.field}`);
} else {
super.handle(request);
}
}
}
class EmailFormatHandler extends AbstractHandler {
handle(request: Record<string, any>) {
if (!/^[^@]+@[^@]+\.[^@]+$/.test(request.email)) {
console.error("Invalid email format");
} else {
super.handle(request);
}
}
}
// Usage
const validator = new RequiredFieldHandler("email");
validator.setNext(new EmailFormatHandler());
validator.handle({ email: "wrong-email" });
Conclusion
The Chain of Responsibility pattern is one of those tools you don’t use every day, but when you do, it drastically improves readability and flexibility.
From Express middleware to logging pipelines to input validation, this pattern is everywhere once you know how to spot it.
✅ Key takeaway: If you find yourself writing endless if-else
checks, ask: Can this be turned into a chain of handlers?
Pro Tip
Combine Chain of Responsibility with Dependency Injection or Strategy Pattern for even more flexibility. You can dynamically swap handlers based on runtime configuration.
Call to Action
Have you ever used the Chain of Responsibility pattern in your projects, maybe without realizing it? Share your experience in the comments 👇
And if you know a teammate drowning in if-else
blocks, send this article their way. They’ll thank you later.
Leave a Reply