Explore practical use cases and detailed examples of the Chain of Responsibility pattern in JavaScript and TypeScript, including logging systems, event propagation, and middleware processing.
The Chain of Responsibility pattern is a powerful behavioral design pattern that allows an object to pass a request along a chain of potential handlers until the request is processed. This pattern is particularly useful in scenarios where multiple objects can handle a request, but the handler is not known beforehand. By decoupling the sender and receiver, the Chain of Responsibility pattern enhances modularity and flexibility in your code.
Before diving into practical examples, let’s briefly recap the core concept of the Chain of Responsibility pattern. In this pattern, a series of handler objects are linked together to form a chain. Each handler decides either to process the request or to pass it along to the next handler in the chain. This approach allows multiple handlers to process the request without the sender needing to know which handler will ultimately handle it.
Let’s explore some practical scenarios where the Chain of Responsibility pattern can be applied effectively:
In a logging system, different log levels (e.g., DEBUG, INFO, WARNING, ERROR) might require different handling. The Chain of Responsibility pattern allows you to create a flexible logging system where each log level is handled by a separate handler.
class Logger {
constructor(level) {
this.level = level;
this.nextLogger = null;
}
setNextLogger(nextLogger) {
this.nextLogger = nextLogger;
}
logMessage(level, message) {
if (this.level <= level) {
this.write(message);
}
if (this.nextLogger !== null) {
this.nextLogger.logMessage(level, message);
}
}
write(message) {
// To be overridden by subclasses
}
}
class ConsoleLogger extends Logger {
write(message) {
console.log("Console Logger: " + message);
}
}
class FileLogger extends Logger {
write(message) {
console.log("File Logger: " + message);
}
}
class ErrorLogger extends Logger {
write(message) {
console.log("Error Logger: " + message);
}
}
// Usage
const errorLogger = new ErrorLogger(3);
const fileLogger = new FileLogger(2);
const consoleLogger = new ConsoleLogger(1);
consoleLogger.setNextLogger(fileLogger);
fileLogger.setNextLogger(errorLogger);
consoleLogger.logMessage(1, "This is an information.");
consoleLogger.logMessage(2, "This is a debug level information.");
consoleLogger.logMessage(3, "This is an error information.");
In this example, we have three types of loggers: ConsoleLogger
, FileLogger
, and ErrorLogger
. Each logger checks if it can handle the log message based on the log level. If it can, it processes the message; otherwise, it passes the message to the next logger in the chain.
abstract class Logger {
protected level: number;
protected nextLogger: Logger | null = null;
constructor(level: number) {
this.level = level;
}
public setNextLogger(nextLogger: Logger): void {
this.nextLogger = nextLogger;
}
public logMessage(level: number, message: string): void {
if (this.level <= level) {
this.write(message);
}
if (this.nextLogger !== null) {
this.nextLogger.logMessage(level, message);
}
}
protected abstract write(message: string): void;
}
class ConsoleLogger extends Logger {
protected write(message: string): void {
console.log("Console Logger: " + message);
}
}
class FileLogger extends Logger {
protected write(message: string): void {
console.log("File Logger: " + message);
}
}
class ErrorLogger extends Logger {
protected write(message: string): void {
console.log("Error Logger: " + message);
}
}
// Usage
const errorLogger = new ErrorLogger(3);
const fileLogger = new FileLogger(2);
const consoleLogger = new ConsoleLogger(1);
consoleLogger.setNextLogger(fileLogger);
fileLogger.setNextLogger(errorLogger);
consoleLogger.logMessage(1, "This is an information.");
consoleLogger.logMessage(2, "This is a debug level information.");
consoleLogger.logMessage(3, "This is an error information.");
In the TypeScript example, we use type annotations to ensure type safety. The structure remains similar to the JavaScript example, but with added benefits of TypeScript’s static typing.
The Chain of Responsibility pattern is inherently used in the DOM’s event propagation mechanism, where an event can be handled by multiple elements in the DOM tree.
// HTML structure
// <div id="parent">
// <button id="child">Click Me!</button>
// </div>
document.getElementById('parent').addEventListener('click', function(event) {
console.log('Parent clicked');
});
document.getElementById('child').addEventListener('click', function(event) {
console.log('Child clicked');
event.stopPropagation(); // Prevents the event from bubbling up to the parent
});
// Try clicking the button and observe the console output
In this example, clicking the button triggers the click
event on both the button and its parent div. The stopPropagation()
method is used to stop the event from bubbling up the DOM tree, demonstrating a manual intervention in the chain of responsibility.
// HTML structure
// <div id="parent">
// <button id="child">Click Me!</button>
// </div>
document.getElementById('parent')?.addEventListener('click', (event: MouseEvent) => {
console.log('Parent clicked');
});
document.getElementById('child')?.addEventListener('click', (event: MouseEvent) => {
console.log('Child clicked');
event.stopPropagation(); // Prevents the event from bubbling up to the parent
});
// Try clicking the button and observe the console output
In the TypeScript example, we use optional chaining (?.
) to ensure that the elements exist before attaching event listeners, adding a layer of type safety.
Middleware in web servers, such as Express.js, is a classic example of the Chain of Responsibility pattern. Each middleware function can process a request, modify it, and pass it to the next middleware in the chain.
const express = require('express');
const app = express();
app.use((req, res, next) => {
console.log('First middleware');
next();
});
app.use((req, res, next) => {
console.log('Second middleware');
next();
});
app.get('/', (req, res) => {
res.send('Hello World!');
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
In this Express.js example, we have two middleware functions. Each middleware logs a message and then calls next()
to pass control to the next middleware in the chain. This pattern allows for flexible request processing and response handling.
import express, { Request, Response, NextFunction } from 'express';
const app = express();
app.use((req: Request, res: Response, next: NextFunction) => {
console.log('First middleware');
next();
});
app.use((req: Request, res: Response, next: NextFunction) => {
console.log('Second middleware');
next();
});
app.get('/', (req: Request, res: Response) => {
res.send('Hello World!');
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
In the TypeScript example, we use type annotations for Request
, Response
, and NextFunction
to ensure type safety and improve code readability.
The Chain of Responsibility pattern enhances modularity by allowing each handler to focus on a specific aspect of request processing. This separation of concerns makes it easier to add, remove, or modify handlers without affecting the entire chain. Additionally, the pattern provides flexibility by allowing the chain to be dynamically configured at runtime.
The Chain of Responsibility pattern is preferred in situations where:
To deepen your understanding of the Chain of Responsibility pattern, try modifying the examples provided:
NetworkLogger
, that sends logs to a remote server.To better understand the flow of requests through a chain of handlers, let’s visualize the Chain of Responsibility pattern using a sequence diagram.
sequenceDiagram participant Client participant Handler1 participant Handler2 participant Handler3 Client->>Handler1: Request Handler1->>Handler2: Pass Request Handler2->>Handler3: Pass Request Handler3-->>Client: Response
In this diagram, the Client
sends a request to Handler1
. If Handler1
cannot process the request, it passes it to Handler2
, and so on, until a handler processes the request or the chain ends.
For more information on the Chain of Responsibility pattern and its applications, consider exploring the following resources:
To reinforce your understanding of the Chain of Responsibility pattern, consider the following questions:
Remember, mastering design patterns is an ongoing journey. Keep experimenting, stay curious, and enjoy the process of learning and applying these powerful concepts in your projects!