Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request.
The Problem
When processing an incoming order, you need to run multiple validation checks — inventory availability, payment method validity, fraud detection, and address verification. Hard-coding all of these checks into a single service creates a monolithic validator that is difficult to extend, reorder, or test in isolation.
The Solution
The Chain of Responsibility pattern organizes validators as a linked list of handler objects. Each handler performs its own check and then delegates to the next handler in the chain. All handlers run regardless of earlier failures, returning a complete list of validation errors.
Structure
Handler (abstract OrderValidator) — Declares validate() and abstract check(), holds a reference to the next handler via setNext().
import { OrderValidator, OrderData, ValidationResult } from './order-validator.interface';export class InventoryCheckHandler extends OrderValidator { private readonly outOfStockItems = ['discontinued-widget', 'old-model-phone']; protected async check(order: OrderData): Promise<ValidationResult> { const errors: string[] = []; for (const item of order.items) { if (this.outOfStockItems.includes(item.name.toLowerCase())) { errors.push(`Item "${item.name}" is out of stock`); } if (item.qty <= 0) { errors.push(`Item "${item.name}" has invalid quantity: ${item.qty}`); } if (item.qty > 100) { errors.push(`Item "${item.name}" exceeds maximum quantity of 100`); } } if (order.items.length === 0) { errors.push('Order must contain at least one item'); } return { valid: errors.length === 0, errors, handlerName: 'InventoryCheck', }; }}
import { OrderValidator, OrderData, ValidationResult } from './order-validator.interface';export class PaymentValidationHandler extends OrderValidator { private readonly validPaymentMethods = ['credit_card', 'debit_card', 'paypal', 'bank_transfer']; protected async check(order: OrderData): Promise<ValidationResult> { const errors: string[] = []; if (!order.paymentMethod) { errors.push('Payment method is required'); } else if (!this.validPaymentMethods.includes(order.paymentMethod.toLowerCase())) { errors.push( `Invalid payment method "${order.paymentMethod}". Accepted: ${this.validPaymentMethods.join(', ')}`, ); } if (order.totalAmount <= 0) { errors.push('Total amount must be greater than zero'); } const calculatedTotal = order.items.reduce((sum, item) => sum + item.price * item.qty, 0); if (Math.abs(calculatedTotal - order.totalAmount) > 0.01) { errors.push( `Total amount mismatch: expected ${calculatedTotal.toFixed(2)}, got ${order.totalAmount.toFixed(2)}`, ); } return { valid: errors.length === 0, errors, handlerName: 'PaymentValidation', }; }}
import { OrderValidator, OrderData, ValidationResult } from './order-validator.interface';export class FraudDetectionHandler extends OrderValidator { private readonly suspiciousCountries = ['XX', 'YY', 'ZZ']; private readonly highValueThreshold = 10000; protected async check(order: OrderData): Promise<ValidationResult> { const errors: string[] = []; if (order.totalAmount > this.highValueThreshold) { errors.push( `Order amount $${order.totalAmount} exceeds fraud threshold of $${this.highValueThreshold}`, ); } if ( order.shippingAddress && this.suspiciousCountries.includes(order.shippingAddress.country?.toUpperCase()) ) { errors.push( `Shipping to country "${order.shippingAddress.country}" is flagged for review`, ); } const hasDuplicateItems = order.items.some( (item, index) => order.items.findIndex((other) => other.name === item.name) !== index, ); if (hasDuplicateItems) { errors.push('Duplicate items detected in order — potential fraud pattern'); } return { valid: errors.length === 0, errors, handlerName: 'FraudDetection', }; }}
import { OrderValidator, OrderData, ValidationResult } from './order-validator.interface';export class AddressValidationHandler extends OrderValidator { protected async check(order: OrderData): Promise<ValidationResult> { const errors: string[] = []; const address = order.shippingAddress; if (!address) { errors.push('Shipping address is required'); return { valid: false, errors, handlerName: 'AddressValidation' }; } if (!address.street || address.street.trim().length === 0) { errors.push('Street address is required'); } if (!address.city || address.city.trim().length === 0) { errors.push('City is required'); } if (!address.zip || address.zip.trim().length === 0) { errors.push('ZIP code is required'); } else if (!/^[A-Za-z0-9\s\-]{3,10}$/.test(address.zip)) { errors.push('Invalid ZIP code format'); } if (!address.country || address.country.trim().length === 0) { errors.push('Country is required'); } else if (address.country.trim().length < 2) { errors.push('Country code must be at least 2 characters'); } return { valid: errors.length === 0, errors, handlerName: 'AddressValidation', }; }}
import { Injectable } from '@nestjs/common';import { OrderValidator, OrderData, ValidationResult } from './order-validator.interface';import { InventoryCheckHandler } from './inventory-check.handler';import { PaymentValidationHandler } from './payment-validation.handler';import { FraudDetectionHandler } from './fraud-detection.handler';import { AddressValidationHandler } from './address-validation.handler';@Injectable()export class ValidationChainService { private readonly chain: OrderValidator; constructor() { const inventory = new InventoryCheckHandler(); const payment = new PaymentValidationHandler(); const fraud = new FraudDetectionHandler(); const address = new AddressValidationHandler(); inventory.setNext(payment).setNext(fraud).setNext(address); this.chain = inventory; } async validate(order: OrderData): Promise<{ isValid: boolean; results: ValidationResult[]; errors: string[]; }> { const results = await this.chain.validate(order); const allErrors = results.flatMap((r) => r.errors); return { isValid: allErrors.length === 0, results, errors: allErrors, }; }}
NestJS Integration
Each handler is decorated with @Injectable() and registered as a provider. The ValidationChainService receives all handlers via constructor injection and assembles the chain in its constructor by calling setNext() on each handler. This leverages NestJS’s DI to manage handler lifecycle while keeping the chain assembly logic centralized.
When to Use
Multiple objects may handle a request and the handler isn’t known upfront.
You want to decouple senders from receivers.
The set of handlers and their order should be configurable at runtime.
When NOT to Use
There is only one handler — a direct call is simpler.
The chain is so long that debugging becomes difficult.