Beyond Basics: Building Scalable TypeScript Applications with Chain of Responsibility Design Pattern

Photo by FLY:D on Unsplash

Beyond Basics: Building Scalable TypeScript Applications with Chain of Responsibility Design Pattern

Chain of Responsibility (CoR) is a behavioral design pattern that passes a request between a chain of objects. In this pattern, each object in the chain can either handle the request or pass it on to the next object in the chain.

Today we will explore how to implement this design pattern in TypeScript.

Using CoR in TypeScript can simplify complex request processing, especially when the chain of objects needs to be changed or extended dynamically. The main idea behind this pattern is to decouple the sender of a request from its receiver by allowing more than one object to handle the request.

Implementing Chain of Responsibility

Let's start with a simple example to understand how the CoR pattern works in TypeScript. Let's say we have a series of objects that need to process a request for a certain document type. In this case, we can create a chain of objects that will handle the request, as shown below:

interface DocumentHandler {
  setNext(handler: DocumentHandler): DocumentHandler;
  handle(documentType: string): string;
}

Now we will create new classes named PDFHandler and TextHandler that implement DocumentHandler interface:

class PDFHandler implements DocumentHandler {
  private nextHandler: DocumentHandler;

  setNext(handler: DocumentHandler): DocumentHandler {
    this.nextHandler = handler;
    return handler;
  }

  handle(documentType: string): string {
    if (documentType === 'pdf') {
      return `Handling PDF Document`;
    } else if (this.nextHandler) {
      return this.nextHandler.handle(documentType);
    } else {
      return `Cannot Handle Document Type: ${documentType}`;
    }
  }
}

class TextHandler implements DocumentHandler {
  private nextHandler: DocumentHandler;

  setNext(handler: DocumentHandler): DocumentHandler {
    this.nextHandler = handler;
    return handler;
  }

  handle(documentType: string): string {
    if (documentType === 'text') {
      return `Handling Text Document`;
    } else if (this.nextHandler) {
      return this.nextHandler.handle(documentType);
    } else {
      return `Cannot Handle Document Type: ${documentType}`;
    }
  }
}

Each class can handle a specific type of document. If a class cannot handle the request, it passes it to the next object in the chain. We can create a chain of objects by calling the setNext() method on each object and passing in the next object in the chain:

const pdfHandler = new PDFHandler();
const textHandler = new TextHandler();

pdfHandler.setNext(textHandler);

console.log(pdfHandler.handle('pdf')); // Output: Handling PDF Document
console.log(pdfHandler.handle('text')); // Output: Handling Text Document
console.log(pdfHandler.handle('excel')); // Output: Cannot Handle Document Type: excel

You can find the complete example in this CodeSandbox.

We can see from the output that the request for each document type is handled by the appropriate object in the chain. If the request cannot be handled by any of the objects in the chain, the last object in the chain returns a message indicating that it cannot handle the request.

Beyond Basics

Now let's look at a real-world example of using the CoR pattern in a web application that processes payments. The payment processing system can have different payment methods, such as credit cards, PayPal, and bank transfers. Each payment method can have different authentication and validation requirements.

To implement this system using the CoR pattern, we can create a chain of objects representing payment methods. Let's start again with the interface and some types:

export type PaymentMethod = "creditcard" | "paypal" | "banktransfer";

export type Payment = {
  method: PaymentMethod;
  amount: number;
  cardNumber: string;
  cvv: string;
  expirationDate: string;
};

interface PaymentHandler {
  setNext(handler: PaymentHandler): PaymentHandler;
  handlePayment(payment: Payment): Promise<boolean>;
}

Each object that implements the PaymentHandler interface can authenticate and validate the corresponding payment method. If an object cannot handle the request, it passes the request to the next object in the chain:

class CreditCardHandler implements PaymentHandler {
  private nextHandler: PaymentHandler;

  setNext(handler: PaymentHandler): PaymentHandler {
    this.nextHandler = handler;
    return handler;
  }

  async handlePayment(payment: Payment): Promise<boolean> {
    if (payment.method === 'creditcard') {
      console.log('Processing Credit Card Payment...');
      return true;
    } else if (this.nextHandler) {
      return this.nextHandler.handlePayment(payment);
    } else {
      return false;
    }
  }
}

class PayPalHandler implements PaymentHandler {
  private nextHandler: PaymentHandler;

  setNext(handler: PaymentHandler): PaymentHandler {
    this.nextHandler = handler;
    return handler;
  }

  async handlePayment(payment: Payment): Promise<boolean> {
    if (payment.method === 'paypal') {
      console.log('Processing PayPal Payment...');
      return true;
    } else if (this.nextHandler) {
      return this.nextHandler.handlePayment(payment);
    } else {
      return false;
    }
  }
}

class BankTransferHandler implements PaymentHandler {
  private nextHandler: PaymentHandler;

  setNext(handler: PaymentHandler): PaymentHandler {
    this.nextHandler = handler;
    return handler;
  }

  async handlePayment(payment: Payment): Promise<boolean> {
    if (payment.method === 'banktransfer') {
      console.log('Processing Bank Transfer Payment...');
      return true;
    } else if (this.nextHandler) {
      return this.nextHandler.handlePayment(payment);
    } else {
      return false;
    }
  }
}

In this example, three classes implement the PaymentHandler interface. Each class represents a payment method and handles the authentication and validation for that payment method. We create a chain of objects by calling the setNext() method on each object and passing in the next object in the chain:

const creditCardHandler = new CreditCardHandler();
const payPalHandler = new PayPalHandler();
const bankTransferHandler = new BankTransferHandler();

creditCardHandler.setNext(payPalHandler).setNext(bankTransferHandler);

const payment: Payment = {
  method: 'creditcard',
  amount: 100,
  cardNumber: '1234567890',
  cvv: '111',
  expirationDate: '01/24'
};

const isPaymentSuccessful = await creditCardHandler.handlePayment(payment);

console.log('Payment Successful:', isPaymentSuccessful);

You can find the complete example in this CodeSandbox.

We can see from the output that the payment request is handled by the appropriate object in the chain based on the payment method. If the payment method cannot be handled by any of the objects in the chain, the last object in the chain returns false.

Middleware in web servers

One great example of using the CoR pattern is the handling of HTTP requests through middlewares in web servers. Let's take an example of Express.js, a very popular Node.js framework. In Express.js, middlewares functions have access to the request object, the response object, and the next middleware in the application’s request-response cycle. Middleware functions can perform tasks such as parsing the request body, handling authentication, performing validation, and many more.

When a request is made to an application, the middleware functions are executed in the order they are defined. Each middleware function can perform operations on the request and/or response objects and pass control to the next middleware function in the chain using the next() function. If a middleware function doesn't call next(), the request-response cycle is terminated, and no further middleware functions are executed.

Middleware functions can be added to or removed from the chain at any point, allowing developers to modify the behavior of their applications easily.

Let's look at an example:

const express = require('express');
const app = express();

// 1. Middleware for parsing a request
app.use((req, res, next) => {
  console.log('Parsing...');
  next();
});

// 2. Middleware for authenticating a request
app.use((req, res, next) => {
  console.log('Authenticating...');
  next();
});

// 3. Route handler middleware for sending a response
app.get('/', (req, res) => {
  res.send('Sending response...');
});

app.listen(3000, () => {
  console.log('Server listening...');
});

In this example, two middleware functions are defined using the app.use() and one using app.get() function. The first middleware function logs a message to the console and calls next() to pass control to the next middleware function. The second middleware function does the same. Finally, a route handler sends a response back to the client. When a request is made to the server, the middleware functions are executed in the order they are defined.

Benefits

Benefits of using the CoR pattern:

  1. Encapsulation: The CoR pattern promotes encapsulation by separating the code that creates a request from the code that handles the request. This makes the code more modular and easier to maintain.

  2. Flexibility: The pattern allows you to easily add, remove or reorder the chain of handlers without affecting other parts of the code. This makes it easier to modify the behavior of an application or add new features.

  3. Extensibility: The pattern makes adding new handlers to the chain easy, allowing you to extend the functionality of an application without modifying existing code.

  4. Decoupling: The pattern reduces the dependencies between classes, which makes the code more loosely coupled and easier to test.

Drawbacks

Here are some aspects of the CoR pattern that should be taken into account when evaluating if it fits your application needs:

  1. Performance: The CoR pattern can introduce performance overhead if the chain of handlers is long or if the handlers are complex.

  2. Difficulty in debugging: The pattern can make it harder to trace the flow of a request through the application, especially if the chain of handlers is long.

  3. Complexity: The pattern can make the code more complex and harder to understand, especially if there are many different types of requests and handlers.

The Chain of Responsibility pattern is a useful pattern for handling requests in a flexible and extensible way. It allows multiple objects to handle requests by chaining them together, and it provides a way to add or remove objects from the chain without affecting the other objects. However, it is important to be aware of both the benefits and drawbacks that may arise when using this pattern.