Chain of Responsibility Design Pattern

Untangle Your Code: Chain of Responsibility Design Pattern

A practical guide to the Chain of Responsibility design pattern in TypeScript. Decouple your code and simplify complex logic.

Introduction

As developers, we’ve all been there: a single function bloated with a lot of responsibilities. It starts simple, and soon, the logic becomes a tangled mess that’s hard to read, maintain, and extend.

The Chain of Responsibility Design Pattern is a behavioral design pattern that offers a clean escape from this complexity.

It lets you pass a request along a chain of handlers. Each handler decides either to process the request or to pass it to the next handler in the chain. This allows you to decouple the sender of a request from its receivers.

Use Case 1: A Messy Signup Form

Imagine you’re building a user registration feature. You need to validate the user’s email, name, and password. Without a clear pattern, you might end up with something like this:

// A single function handling all validation
function validateRegistration(data: any) {
    // Validation for email
    if (data.email && data.email.includes('@')) {
        console.log("Email seems okay.");
        // Nested validation for name
        if (data.name && data.name.length > 2) {
            console.log("Name is fine.");
            // More nested validation for password
            if (data.password && data.password.length > 5) {
                console.log("Password is valid. All checks passed!");
                return true;
            } else {
                console.log("Error: Password is too short.");
                return false;
            }
        } else {
            console.log("Error: Name is too short.");
            return false;
        }
    } else {
        console.log("Error: Invalid email.");
        return false;
    }
}

const formData = {
    email: "test@test.com",
    name: "test",
    password: "123456"
};

validateRegistration(formData);

This code works, but it’s rigid. What if you need to add a new validation rule, like checking for password strength? You’d have to dive back into this nested logic, increasing the risk of breaking something.

The Solution: A Clean Chain of Validators

The Chain of Responsibility pattern solves this by breaking each validation rule into its own handler class. These handlers are then linked together in a sequence, or a “chain.”

A request enters the start of the chain and travels from handler to handler until one of them handles it or it reaches the end. For validation, if any handler finds an issue, it can break the chain.

Here is how we can refactor our validation logic using the Chain of Responsibility pattern in TypeScript. This example creates a flexible input validation chain.

// Define the contract for every handler in the chain
interface Handler {
    setNext(handler: Handler): Handler;
    handle(): boolean | undefined;
}

// An abstract base class to provide default chaining behavior
abstract class BaseHandler implements Handler {
    private nextHandler: Handler | undefined;

    // Sets the next handler in the chain and returns it,
    // allowing us to link handlers together fluently.
    public setNext(handler: Handler): Handler {
        this.nextHandler = handler;
        return handler;
    }

    // Executes the next handler if it exists. Subclasses will
    // call this after their own logic passes.
    public handle(): boolean | undefined {
        if (this.nextHandler) {
            return this.nextHandler.handle();
        }
        // If this is the end of the chain, return true to signify success
        return true;
    }
}

// Concrete handler for validating an email
class IsEmailHandler extends BaseHandler {
    private email: string;

    constructor(email: string) {
        super();
        this.email = email;
    }

    public handle(): boolean | undefined {
        const isValid = !!this.email && this.email.includes('@');

        if (isValid) {
            console.log("Valid email, passing to the next validator...");
            // If valid, call the next handler in the chain
            return super.handle();
        }

        console.log("Not a valid email, breaking the chain.");
        return false; // Break the chain
    }
}

// Concrete handler for validating a name
class IsNameValidHandler extends BaseHandler {
    private name: string;

    constructor(name: string) {
        super();
        this.name = name;
    }

    public handle(): boolean | undefined {
        const isValid = !!this.name && this.name.length > 2;

        if (isValid) {
            console.log("Valid name, passing to the next validator...");
            return super.handle();
        }
        
        console.log("Not a valid name, breaking the chain.");
        return false;
    }
}

// Concrete handler for validating a password
class IsPasswordValidHandler extends BaseHandler {
    private password: string;

    constructor(password: string) {
        super();
        this.password = password;
    }

    public handle(): boolean | undefined {
        const isValid = !!this.password && this.password.length > 5;

        if (isValid) {
            console.log("Valid password, all checks passed!");
            return super.handle();
        }
        
        console.log("Not a valid password, breaking the chain.");
        return false;
    }
}

// --- Client Code ---
const userFormData = {
    email: "test@test.com",
    name: "test",
    password: "123456"
};

// Create instances of our validators
const emailValidator = new IsEmailHandler(userFormData.email);
const nameValidator = new IsNameValidHandler(userFormData.name);
const passwordValidator = new IsPasswordValidHandler(userFormData.password);

// Build the chain of responsibility
emailValidator.setNext(nameValidator).setNext(passwordValidator);

// Start the validation process from the first handler
console.log("Starting validation...");
const isAllValid = emailValidator.handle();

if (isAllValid) {
    console.log("Registration successful!");
} else {
    console.log("Registration failed.");
}

Yes, for sure, more code, but have a closer look, it is significantly much cleaner. Each class has a single responsibility. Adding a new rule is as simple as creating a new handler class and inserting it into the chain, without modifying any existing code, which applies the Open-Closed Principle.

Use Case 2: API Middleware

This is a very common use case. In web frameworks like Express.js, middleware functions form a chain of responsibility. An incoming HTTP request is passed through a series of middleware functions.

  • An AuthMiddleware checks for a valid user token.
  • A RoleMiddleware checks if the user has permission for the action.
  • A LoginHandler handles the actual login logic.

Each middleware can either pass the request to the next one or end the cycle by sending a response (e.g., a 401 Unauthorized error). This answers the common middleware vs chain of responsibility question: middleware is a powerful, real-world implementation of this pattern.

// --- 1. Define the Request and Middleware Interface ---

// A simple representation of an incoming API request.
// The `user` property is optional because it's added by the AuthMiddleware.
interface ApiRequest {
    headers: {
        token?: string;
    };
    body: {
        data: string;
    };
    user?: {
        id: number;
        role: 'admin' | 'user';
    };
}

// The contract for any handler (middleware) in our chain.
interface Middleware {
    setNext(handler: Middleware): Middleware;
    handle(request: ApiRequest): { success: boolean; message: string; data?: any };
}

// --- 2. Create a Base Middleware Class for common logic ---

abstract class BaseMiddleware implements Middleware {
    private nextHandler: Middleware | null = null;

    // This sets the next middleware in the chain.
    public setNext(handler: Middleware): Middleware {
        this.nextHandler = handler;
        return handler;
    }

    // This runs the next middleware if it exists.
    // Each concrete middleware will call this method if its check passes.
    public handle(request: ApiRequest): { success: boolean; message: string; data?: any } {
        if (this.nextHandler) {
            return this.nextHandler.handle(request);
        }
        // This is the default success response if it's the end of the chain.
        return { success: true, message: 'Request processed successfully.' };
    }
}

// --- 3. Implement Concrete Middleware Handlers ---

/**
 * Checks for a valid authentication token. If found, it "enriches" the
 * request by adding user information before passing it on.
 */
class AuthMiddleware extends BaseMiddleware {
    public handle(request: ApiRequest): { success: boolean; message: string; data?: any } {
        console.log('Checking authentication...');

        // In a real app, you'd validate a real token (e.g., JWT).
        if (request.headers.token === 'my-secret-token') {
            // Token is valid. Attach user info and pass to the next middleware.
            request.user = { id: 1, role: 'admin' };
            console.log('Authentication successful. User attached.');
            return super.handle(request);
        }

        // Token is invalid, so we break the chain here.
        console.log('Authentication failed: Invalid token.');
        return { success: false, message: '401 Unauthorized: Invalid token.' };
    }
}

/**
 * Checks if the user has the required role. This middleware assumes
 * AuthMiddleware has already run and attached a user object.
 */
class RoleMiddleware extends BaseMiddleware {
    private requiredRole: 'admin' | 'user';

    constructor(role: 'admin' | 'user') {
        super();
        this.requiredRole = role;
    }

    public handle(request: ApiRequest): { success: boolean; message:string; data?: any } {
        console.log(`Checking authorization for role: ${this.requiredRole}...`);

        if (request.user?.role === this.requiredRole) {
            // User has the required role. Pass to the next middleware.
            console.log('Authorization successful.');
            return super.handle(request);
        }

        // User does not have the required role, so we break the chain.
        console.log('Authorization failed: Insufficient permissions.');
        return { success: false, message: '403 Forbidden: Insufficient permissions.' };
    }
}

/**
 * This is the final handler, representing the actual business logic
 */
class LoginHandler extends BaseMiddleware {
     public handle(request: ApiRequest): { success: boolean; message: string; data?: any } {
        console.log('Executing the login action...');
        // All checks passed, so process the request.
        return {
            success: true,
            message: 'Data processed by login.',
            data: `Hello, user ${request.user?.id}! You processed: ${request.body.data}`,
        };
     }
}


// --- 4. Client Code: Build and Run the Chain ---

// Create the middleware instances
const authCheck = new AuthMiddleware();
const adminCheck = new RoleMiddleware('admin');
const finalAction = new LoginHandler();

// Build the chain: Auth -> Role -> Action
authCheck.setNext(adminCheck).setNext(finalAction);


// --- TEST CASES ---

console.log('--- Test Case 1: Successful Request ---');
const successfulRequest: ApiRequest = {
    headers: { token: 'my-secret-token' },
    body: { data: 'important data' },
};
let result = authCheck.handle(successfulRequest);
console.log('Result:', result, '\n');


console.log('--- Test Case 2: Authentication Failure ---');
const failedAuthRequest: ApiRequest = {
    headers: { token: 'invalid-token' },
    body: { data: 'some data' },
};
result = authCheck.handle(failedAuthRequest);
console.log('Result:', result, '\n');


console.log('--- Test Case 3: Authorization Failure ---');
// To test this, we'll temporarily change the user's role after a valid auth.
// In a real scenario, this would come from the database lookup in AuthMiddleware.
class MockAuthMiddleware extends BaseMiddleware {
     public handle(request: ApiRequest) {
        request.user = { id: 2, role: 'user' }; // Mock a non-admin user
        return super.handle(request)
     }
}
const mockAuth = new MockAuthMiddleware()
mockAuth.setNext(adminCheck).setNext(finalAction)
const failedRoleRequest: ApiRequest = {
    headers: { token: 'my-secret-token' }, // Auth token is valid
    body: { data: 'admin data' },
};
result = mockAuth.handle(failedRoleRequest);
console.log('Result:', result);

So, as you saw, each handler has its own responsibility, which increases the decoupling. On top of that, you might have noticed from the third test case that it makes unit testing remarkably much easier.

When to Use the Chain of Responsibility Pattern

  • When you want to decouple the object that sends a request from the objects that process it.
  • When you need to execute handlers in a specific order.
  • When you want to add or remove responsible objects at runtime.

Conclusion

In short, the Chain of Responsibility pattern is more than just a theoretical concept; it’s a practical solution to a common coding problem. By transforming a monolithic block of logic into a flexible chain of independent handlers, you gain code that is easier to read, test, and extend.

Refer to these resources:

Think about it

If you enjoyed this article, I’d truly appreciate it if you could share it—it really motivates me to keep creating more helpful content!

If you’re interested in exploring more, check out these articles.

Thanks for sticking with me until the end—I hope you found this article valuable and enjoyable!

Want more dev insights like this? Subscribe to get practical tips, tutorials, and tech deep dives delivered to your inbox. No spam, unsubscribe anytime.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top