DEV Community

Cover image for Design Pattern #10: Breaking the Chain of Irresponsibility
Serhii Korol
Serhii Korol

Posted on

Design Pattern #10: Breaking the Chain of Irresponsibility

Hi everyone! Today, I’d like to talk about a design pattern that you’ve probably heard of but might not use very often. In fact, many of us apply it without even realizing it—the Chain of Responsibility pattern.
We’ll look at the key benefits it provides, walk through a relatable real-life scenario, and implement it step by step. Afterwards, we’ll see how we can take the example further and improve upon it.
Let’s get started!

What's the Chain of Responsibility?

uml

The core idea of this pattern is that you have several ordered handlers linked together in a chain. When the client sends a request, it first reaches the initial handler. That handler can choose to:

  • process the request and pass it along,
  • simply forward it to the next handler, or
  • stop the chain entirely by rejecting the request.

Each handler makes its own decision about what happens next. In essence, the Chain of Responsibility is a pipeline where the request must pass through handlers in a defined order.
A familiar example of this pattern can be found in ASP.NET projects:

var app = builder.Build();
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
app.MapStaticAssets();
app.Run();
Enter fullscreen mode Exit fullscreen mode

You can’t skip a handler or change their order without breaking the expected behavior, which can easily lead to errors.

Yes, you can. Let’s look at a scenario where requests must be processed in a strict order.
Consider a common case—purchasing products in an online shop. Before the payment is made, you first need to verify product availability, then validate the customer’s payment details. Once these checks are complete, the customer proceeds with the payment.
At this stage, several outcomes are possible:

  • The payment may fail due to insufficient funds.
  • The bank may decline the transaction.
  • In both cases, you must return the reserved product to the warehouse.

If the payment succeeds, the next step is shipping the product. However, the customer may later cancel the order. In that situation, you’ll need to refund the payment and restock the product.
How would this process look in a straightforward, non-pattern-based implementation?
Let’s start with a simple Customer model containing an ID, Name, and Balance:

public class Customer(int id, string name, decimal balance)
{
    public int Id { get; set; } = id;
    public string Name { get; set; } = name ?? throw new ArgumentNullException(nameof(name));

    public decimal Balance { get; set; } =
        balance >= 0 ? balance : throw new ArgumentException("Balance cannot be negative", nameof(balance));
}
Enter fullscreen mode Exit fullscreen mode

We also have a Product model, which includes ID, Name, Price, and Stock.

public class Product(int id, string name, decimal price, int stock)
{
    public int Id { get; set; } = id;
    public string Name { get; set; } = name ?? throw new ArgumentNullException(nameof(name));

    public decimal Price { get; set; } =
        price >= 0 ? price : throw new ArgumentException("Price cannot be negative", nameof(price));

    public int Stock { get; set; } =
        stock >= 0 ? stock : throw new ArgumentException("Stock cannot be negative", nameof(stock));
}
Enter fullscreen mode Exit fullscreen mode

Now we need to create an order processing flow, where each request is handled step by step.

public class Order(
    IInventoryService inventoryService,
    IPaymentService paymentService,
    IShippingService shippingService)
{
    private readonly IInventoryService _inventoryService = inventoryService ?? throw new ArgumentNullException(nameof(inventoryService));
    private readonly IPaymentService _paymentService = paymentService ?? throw new ArgumentNullException(nameof(paymentService));
    private readonly IShippingService _shippingService = shippingService ?? throw new ArgumentNullException(nameof(shippingService));

    public PurchaseResponse Purchase(Customer? customer, Product? product, int quantity = 1)
    {
        if (customer == null)
            return CreateErrorResponse(PurchaseResult.InvalidInput, "Customer cannot be null", 0, 0);

        if (product == null)
            return CreateErrorResponse(PurchaseResult.InvalidInput, "Product cannot be null", 0, 0);

        if (quantity <= 0)
            return CreateErrorResponse(PurchaseResult.InvalidInput, "Quantity must be greater than zero",
                customer.Balance, product.Stock);


        var totalPrice = product.Price * quantity;

        if (product.Stock < quantity)
        {
            var message = $"Purchase failed: Insufficient stock. Required: {quantity}, Available: {product.Stock}";
            return CreateErrorResponse(PurchaseResult.OutOfStock, message, customer.Balance, product.Stock);
        }

        if (customer.Balance < totalPrice)
        {
            var message =
                $"Purchase failed: Insufficient funds. Required: {totalPrice:C}, Available: {customer.Balance:C}";
            return CreateErrorResponse(PurchaseResult.InsufficientFunds, message, customer.Balance, product.Stock);
        }

        if (!_inventoryService.ReserveProduct(product, quantity))
        {
            const string message = "Purchase failed: Unable to reserve product";
            return CreateErrorResponse(PurchaseResult.OutOfStock, message, customer.Balance, product.Stock);
        }


        try
        {
            if (!_paymentService.ProcessPayment(customer, totalPrice))
            {
                _inventoryService.ReleaseProduct(product, quantity);
                const string message = "Purchase failed: Payment processing failed";
                return CreateErrorResponse(PurchaseResult.PaymentFailed, message, customer.Balance, product.Stock);
            }

            if (!_shippingService.ShipProduct(customer, product))
            {
                _inventoryService.ReleaseProduct(product, quantity);
                _paymentService.RefundPayment(customer, totalPrice);
                const string message = "Purchase failed: Shipment processing failed";
                return CreateErrorResponse(PurchaseResult.ShippingFailed, message, customer.Balance, product.Stock);
            }

            var successMessage =
                $"Purchase completed successfully! Customer: {customer.Name}, Product: {product.Name}, Quantity: {quantity}, Total: {totalPrice:C}";

            return new PurchaseResponse
            {
                Result = PurchaseResult.Success,
                Message = successMessage,
                RemainingBalance = customer.Balance,
                RemainingStock = product.Stock,
                ProcessedAt = DateTime.Now
            };
        }
        catch (Exception ex)
        {
            _paymentService.RefundPayment(customer, totalPrice);
            _inventoryService.ReleaseProduct(product, quantity);

            var errorMessage = $"Purchase failed due to unexpected error: {ex.Message}";
            return CreateErrorResponse(PurchaseResult.InvalidInput, errorMessage, customer.Balance, product.Stock);
        }
    }

    private static PurchaseResponse CreateErrorResponse(PurchaseResult result, string message, decimal balance,
        int stock)
    {
        return new PurchaseResponse
        {
            Result = result,
            Message = message,
            RemainingBalance = balance,
            RemainingStock = stock
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

First, we need to validate the input data: the Customer must not be null, and the Product must also be valid. If either check fails, we return an error response.

public class PurchaseResponse
{
    public PurchaseResult Result { get; init; }
    public string? Message { get; init; }
    public decimal RemainingBalance { get; init; }
    public int RemainingStock { get; init; }
    public DateTime ProcessedAt { get; init; }

    public bool IsSuccessful => Result == PurchaseResult.Success;
}

public enum PurchaseResult
{
    Success,
    OutOfStock,
    InsufficientFunds,
    InvalidInput,
    PaymentFailed,
    ShippingFailed
}
Enter fullscreen mode Exit fullscreen mode

Next, we check the product’s stock and the customer’s balance. If the customer requests a quantity greater than what’s available, we return an error. Similarly, if the customer has insufficient funds, we also return an error. In a real-world application, these checks could be delegated to separate services, but here we’ll keep things simple.

When all conditions are met, you can reserve products in Stock.

public interface IInventoryService
{
    bool ReserveProduct(Product product, int quantity = 1);
    void ReleaseProduct(Product product, int quantity = 1);
}

public class InventoryService : IInventoryService
{
    public bool ReserveProduct(Product product, int quantity = 1)
    {
        if (product.Stock < quantity) return false;
        product.Stock -= quantity;
        return true;
    }

    public void ReleaseProduct(Product product, int quantity = 1)
    {
        product.Stock += quantity;
    }
}
Enter fullscreen mode Exit fullscreen mode

The service is responsible for adjusting product stock—decreasing it when items are reserved and increasing it when they’re returned. The ReserveProduct method indicates whether the product was successfully reserved or if it’s already unavailable.

The next step is for the customer to complete the payment, which is handled by a separate payment service.

public interface IPaymentService
{
    bool ProcessPayment(Customer customer, decimal amount);
    void RefundPayment(Customer customer, decimal amount);
}

public class PaymentService : IPaymentService
{
    public bool ProcessPayment(Customer customer, decimal amount)
    {
        if (customer.Balance < amount) return false;
        customer.Balance -= amount;
        return true;
    }

    public void RefundPayment(Customer customer, decimal amount)
    {
        customer.Balance += amount;
    }
}

Enter fullscreen mode Exit fullscreen mode

This service deducts the payment from the customer’s balance and reports whether the transaction was successful. If something goes wrong, the money should be refunded to the customer.

Finally, the last step in the chain is Shipment, where the service confirms that the product has been dispatched.

public interface IShippingService
{
    bool ShipProduct(Customer customer, Product product);
}

public class ShippingService() : IShippingService
{
    public bool ShipProduct(Customer customer, Product product)
    {
        try
        {
            Console.WriteLine($"Product '{product.Id}' shipped to customer '{customer.Id}'");
            return true;
        }
        catch (Exception)
        {
            return false;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If the Customer cancels a purchase, issue a refund and return the item to inventory. In the event of critical errors, issue a full refund.

In testing, we can consistently reproduce one successful request and two failed requests.

    public void NaiveApproach()
    {
        var inventoryService = new InventoryService();
        var paymentService = new PaymentService();
        var shippingService = new ShippingService();

        var orderService = new Order(inventoryService, paymentService, shippingService);

        var customer = new Customer(1, "John Doe", 1000m);
        var product = new Product(1, "Gaming Laptop", 899.99m, 5);

        Console.WriteLine("=== Successful Purchase ===");
        var result1 = orderService.Purchase(customer, product);

        PrintResult(result1.Result.ToString(),
            result1.Message,
            result1.RemainingBalance,
            result1.RemainingStock,
            result1.IsSuccessful,
            result1.ProcessedAt);

        Console.WriteLine("\n=== Insufficient Funds ===");
        var expensiveProduct = new Product(2, "Luxury Car", 50000m, 2);
        var result2 = orderService.Purchase(customer, expensiveProduct);

        PrintResult(result2.Result.ToString(),
            result2.Message,
            result2.RemainingBalance,
            result2.RemainingStock,
            result2.IsSuccessful,
            result2.ProcessedAt);

        Console.WriteLine("\n=== Out of Stock ===");
        var result3 = orderService.Purchase(customer, product, 10);
        PrintResult(result3.Result.ToString(),
            result3.Message,
            result3.RemainingBalance,
            result3.RemainingStock,
            result3.IsSuccessful,
            result3.ProcessedAt);
    }
Enter fullscreen mode Exit fullscreen mode

Taken together, this code is tightly coupled. It works now, but it is likely to be brittle and difficult to maintain over time.

How can we improve it?

We will refactor the code and refine the Chain of Responsibility pattern. The Customer and Product models remain unchanged. We will introduce a Context model to carry request-specific data (similar to HttpContext).

public class PurchaseContext
{
    public Customer? Customer { get; init; }
    public Product? Product { get; init; }
    public int Quantity { get; init; }
    public decimal TotalPrice { get; set; }
    public bool IsProcessed { get; set; }
    public PurchaseResult Result { get; set; }
    public string? ErrorMessage { get; set; }
    public DateTime ProcessedAt { get; set; }
    public bool ProductReserved { get; set; }
    public bool PaymentProcessed { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Next, we’ll implement the pipeline using handlers instead of services. This component will create the context, execute the pipeline, and return the response.

public class OrderProcessingService
{
    private readonly PurchaseHandler _handlerChain;

    public OrderProcessingService()
    {
        var inputValidator = new InputValidationHandler();
        var stockValidator = new StockValidationHandler();
        var balanceValidator = new BalanceValidationHandler();
        var inventoryReservation = new InventoryReservationHandler();
        var paymentProcessor = new PaymentProcessingHandler();
        var shippingHandler = new ShippingHandler();

        inputValidator
            .SetNext(stockValidator)
            .SetNext(balanceValidator)
            .SetNext(inventoryReservation)
            .SetNext(paymentProcessor)
            .SetNext(shippingHandler);

        _handlerChain = inputValidator;
    }

    public PurchaseResponse ProcessOrder(Customer customer, Product product, int quantity = 1)
    {
        var context = new PurchaseContext
        {
            Customer = customer,
            Product = product,
            Quantity = quantity
        };

        var processedContext = _handlerChain.Handle(context);

        var response = new PurchaseResponse
        {
            Result = processedContext.Result,
            Message = processedContext.Result == PurchaseResult.Success
                ? "Purchase completed successfully!"
                : processedContext.ErrorMessage,
            RemainingBalance = customer.Balance,
            RemainingStock = product.Stock,
            ProcessedAt = processedContext.ProcessedAt
        };

        return response;
    }
}
Enter fullscreen mode Exit fullscreen mode

The handlers are composed in a specific order. We define six handlers, each with a single responsibility. The pipeline performs the same business actions as before, but the logic is now encapsulated within handlers. Each handler derives from a base handler that provides two methods: one to process the request using the context, and another to set the next handler in the chain.

public abstract class PurchaseHandler
{
    private PurchaseHandler? _nextHandler;

    public PurchaseHandler SetNext(PurchaseHandler handler)
    {
        _nextHandler = handler;
        return handler;
    }

    public PurchaseContext Handle(PurchaseContext context)
    {
        if (context.IsProcessed)
            return context;

        try
        {
            ProcessRequest(context);

            if (!context.IsProcessed && _nextHandler != null)
            {
                return _nextHandler.Handle(context);
            }
        }
        catch (Exception ex)
        {
            context.Result = PurchaseResult.InvalidInput;
            context.ErrorMessage = ex.Message;
            context.IsProcessed = true;

            Rollback(context);
        }

        return context;
    }

    protected abstract void ProcessRequest(PurchaseContext context);

    protected virtual void Rollback(PurchaseContext context)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, handlers are responsible for specific action.

public class BalanceValidationHandler : PurchaseHandler
{
    protected override void ProcessRequest(PurchaseContext context)
    {
        if (context.Customer == null || context.Customer.Balance >= context.TotalPrice) return;
        context.Result = PurchaseResult.InsufficientFunds;
        context.ErrorMessage =
            $"Insufficient funds. Required: {context.TotalPrice:C}, Available: {context.Customer.Balance:C}";
        context.IsProcessed = true;
    }
}

public class InputValidationHandler : PurchaseHandler
{
    protected override void ProcessRequest(PurchaseContext context)
    {
        if (context.Customer == null)
        {
            context.Result = PurchaseResult.InvalidInput;
            context.ErrorMessage = "Customer cannot be null";
            context.IsProcessed = true;
            return;
        }

        if (context.Product == null)
        {
            context.Result = PurchaseResult.InvalidInput;
            context.ErrorMessage = "Product cannot be null";
            context.IsProcessed = true;
            return;
        }

        if (context.Quantity <= 0)
        {
            context.Result = PurchaseResult.InvalidInput;
            context.ErrorMessage = "Quantity must be greater than zero";
            context.IsProcessed = true;
            return;
        }

        context.TotalPrice = context.Product.Price * context.Quantity;
    }
}

public class InventoryReservationHandler : PurchaseHandler
{
    protected override void ProcessRequest(PurchaseContext context)
    {
        if (context.Product == null) return;
        context.Product.Stock -= context.Quantity;
        context.ProductReserved = true;
    }

    protected override void Rollback(PurchaseContext context)
    {
        if (!context.ProductReserved) return;
        if (context.Product != null) context.Product.Stock += context.Quantity;
        context.ProductReserved = false;
    }
}

public class PaymentProcessingHandler : PurchaseHandler
{
    protected override void ProcessRequest(PurchaseContext context)
    {
        if (context.Customer != null && context.Customer.Balance >= context.TotalPrice)
        {
            context.Customer.Balance -= context.TotalPrice;
            context.PaymentProcessed = true;
        }
        else
        {
            context.Result = PurchaseResult.PaymentFailed;
            context.ErrorMessage = "Payment processing failed";
            context.IsProcessed = true;
        }
    }

    protected override void Rollback(PurchaseContext context)
    {
        if (context.PaymentProcessed)
        {
            if (context.Customer != null) context.Customer.Balance += context.TotalPrice;
            context.PaymentProcessed = false;
        }

        if (!context.ProductReserved) return;
        if (context.Product != null) context.Product.Stock += context.Quantity;
        context.ProductReserved = false;
    }
}

public class ShippingHandler : PurchaseHandler
{
    protected override void ProcessRequest(PurchaseContext context)
    {
        try
        {
            context.Result = PurchaseResult.Success;
            context.ProcessedAt = DateTime.Now;
            context.IsProcessed = true;
        }
        catch (Exception ex)
        {
            context.Result = PurchaseResult.ShippingFailed;
            context.ErrorMessage = $"Shipping failed: {ex.Message}";
            context.IsProcessed = true;
            throw;
        }
    }

    protected override void Rollback(PurchaseContext context)
    {
        if (context.PaymentProcessed)
        {
            if (context.Customer != null) context.Customer.Balance += context.TotalPrice;
            context.PaymentProcessed = false;
        }

        if (!context.ProductReserved) return;
        if (context.Product != null) context.Product.Stock += context.Quantity;
        context.ProductReserved = false;
    }
}

public class StockValidationHandler : PurchaseHandler
{
    protected override void ProcessRequest(PurchaseContext context)
    {
        if (context.Product == null || context.Product.Stock >= context.Quantity) return;
        context.Result = PurchaseResult.OutOfStock;
        context.ErrorMessage = $"Insufficient stock. Required: {context.Quantity}, Available: {context.Product.Stock}";
        context.IsProcessed = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

The code for testing is almost the same.

    public void CoRApproach()
    {
        var orderService = new OrderProcessingService();

        var customer = new CoR.Models.Customer(1, "John Doe", 1000m);
        var product = new CoR.Models.Product(1, "Gaming Laptop", 899.99m, 5);

        Console.WriteLine("=== Successful Purchase ===");
        var result1 = orderService.ProcessOrder(customer, product);
        PrintResult(result1.Result.ToString(),
            result1.Message,
            result1.RemainingBalance,
            result1.RemainingStock,
            result1.IsSuccessful,
            result1.ProcessedAt);

        Console.WriteLine("\n=== Insufficient Funds ===");
        var expensiveProduct = new CoR.Models.Product(2, "Luxury Car", 50000m, 2);
        var result2 = orderService.ProcessOrder(customer, expensiveProduct);
        PrintResult(result2.Result.ToString(),
            result2.Message,
            result2.RemainingBalance,
            result2.RemainingStock,
            result2.IsSuccessful,
            result2.ProcessedAt);

        Console.WriteLine("\n=== Out of Stock ===");
        var result3 = orderService.ProcessOrder(customer, product, 10);
        PrintResult(result3.Result.ToString(),
            result3.Message,
            result3.RemainingBalance,
            result3.RemainingStock,
            result3.IsSuccessful,
            result3.ProcessedAt);
    }
Enter fullscreen mode Exit fullscreen mode

-In summary, the code is now less coupled and more cohesive. You can extend the service without friction—simply add a new handler.

Benchmarks

With the refactor complete, we can now compare the performance of the two approaches.

benchmark

As shown, the Chain of Responsibility approach excels not only in readability and extensibility, but also in performance.

Conclusion

There’s no reason not to use the Chain of Responsibility pattern. It offers:

  • Top performance
  • Excellent readability
  • Strong extensibility
  • Low coupling
  • High cohesion

I hope you found this guide helpful and that it encourages you to implement similar solutions in your own projects.

☕ If you liked this post, consider supporting me:
Buy Me A Beer

Top comments (0)