Chain of Responsibility Design Pattern in C#: Passing the Buck, One Object at a Time

Sudhir ManglaSudhir Mangla
6 min read

"Originally published on [DevelopersVoice ], reposted here for visibility and feedback from the awesome dev.to community"

Ever felt like handling requests is a chaotic game of hot potato? Throwing requests between objects, hoping someone handles it? Sounds messy.

There's a cleaner way: the Chain of Responsibility pattern. It helps tame unruly, cascading requests like passing a baton in a relay race—smooth and efficient.

Let's dive into the Chain of Responsibility (CoR) pattern, focusing on C#. By the end, you'll get it.


What's the Chain of Responsibility Pattern?

You have an incoming request (logging, auth, approval). Multiple handlers could process it, but you don't know which one will.

The Chain of Responsibility pattern lets you pass a request along a chain of handlers. Each handler decides either to process the request or pass it to the next handler in the chain.

Think of a corporate approval ladder: Junior manager -> Senior manager -> VP -> CEO. If one can't handle it, it goes up the chain.


Core Principles of CoR

Mastering the pattern means understanding its foundation:

  1. Decoupling Sender and Receiver: The sender doesn't know who handles the request. This promotes flexibility.

  2. Single Responsibility Principle (SRP): Each handler does one thing: handle the request or pass it on. Clean and concise.

  3. Open/Closed Principle (OCP): You can add new handlers (open for extension) without changing existing ones (closed for modification).


When Should You Use CoR?

CoR is powerful but best suited for specific scenarios. Use it if:

  • Multiple objects might handle a request?

  • You want to avoid hardcoding the handler logic in the client?

  • Handling logic changes often, requiring flexibility?

Common use cases:

  • Logging systems (different levels handling different severities)

  • Auth/Authz handlers

  • GUI event bubbling

  • Approval workflows (like expense reports)

  • Validation chains


Key Components

  • Handler Interface/Abstract Class: Defines the HandleRequest method and holds a reference (successor) to the next handler.

  • Concrete Handlers: Implement the Handler. Decide whether to handle the request or call successor.HandleRequest(request).

  • Client: Initiates the request to the first handler in the chain.

Analogy: A package delivery chain (warehouse -> shipping center -> local hub -> door). If one link fails, the next might handle it.


CoR in C#: Expense Approval Example

Let's build an expense approval system:

  • Supervisor: Approves < $1000

  • Manager: Approves $1000 - $4999.99

  • Director: Approves >= $5000

Step 1: Define the Handler Abstraction

// The Handler abstract class
public abstract class Approver
{
    protected Approver successor;

    public void SetSuccessor(Approver successor)
    {
        this.successor = successor;
    }

    public abstract void ProcessRequest(Expense expense);
}

Step 2: Define the Request (Expense)

// Expense class representing the request
public class Expense
{
    public int Amount { get; }
    public string Purpose { get; }

    public Expense(int amount, string purpose)
    {
        Amount = amount;
        Purpose = purpose;
    }
}

Step 3: Create Concrete Handlers

// Supervisor handler
public class Supervisor : Approver
{
    public override void ProcessRequest(Expense expense)
    {
        if (expense.Amount < 1000)
        {
            Console.WriteLine($"Supervisor approved ${expense.Amount} for {expense.Purpose}");
        }
        else if (successor != null)
        {
            successor.ProcessRequest(expense);
        }
    }
}

// Manager handler
public class Manager : Approver
{
    public override void ProcessRequest(Expense expense)
    {
        if (expense.Amount >= 1000 && expense.Amount < 5000) // Adjusted condition for clarity
        {
            Console.WriteLine($"Manager approved ${expense.Amount} for {expense.Purpose}");
        }
        else if (successor != null)
        {
            successor.ProcessRequest(expense);
        }
    }
}

// Director handler (final link)
public class Director : Approver
{
    public override void ProcessRequest(Expense expense)
    {
        if (expense.Amount >= 5000)
        {
            Console.WriteLine($"Director approved ${expense.Amount} for {expense.Purpose}");
        }
        // No successor check needed if it's the end of the chain,
        // or could add logging for unhandled requests if necessary.
    }
}

Step 4: Client Code - Using the Chain

class Program
{
    static void Main()
    {
        // Create approvers
        Approver supervisor = new Supervisor();
        Approver manager = new Manager();
        Approver director = new Director();

        // Link them into a chain
        supervisor.SetSuccessor(manager);
        manager.SetSuccessor(director);

        // Submit expenses
        Expense expense1 = new Expense(500, "Team Lunch");
        Expense expense2 = new Expense(2500, "Conference Fees");
        Expense expense3 = new Expense(7500, "New Office Furniture");

        supervisor.ProcessRequest(expense1); // Handled by Supervisor
        supervisor.ProcessRequest(expense2); // Handled by Manager
        supervisor.ProcessRequest(expense3); // Handled by Director

        Console.ReadKey();
    }
}

Output:

Supervisor approved $500 for Team Lunch
Manager approved $2500 for Conference Fees
Director approved $7500 for New Office Furniture

Clean, right? Each handler has its role. Compare this to nested if-else chaos!


Alternative Implementations in C

The classic OOP approach is great, but C# offers other ways:

1. Using Delegates (Functional CoR)

A concise, functional style:

// Functional-style Chain using Delegates
public delegate bool ApprovalHandler(Expense expense);

public class ExpenseProcessor
{
    private readonly List<ApprovalHandler> handlers = new List<ApprovalHandler>();

    public ExpenseProcessor AddHandler(ApprovalHandler handler)
    {
        handlers.Add(handler);
        return this; // Fluent API style
    }

    public void ProcessExpense(Expense expense)
    {
        foreach (var handler in handlers)
        {
            if (handler(expense)) // If handler returns true, it handled the request
                break;
        }
    }
}

// Usage:
var processor = new ExpenseProcessor();
processor.AddHandler(expense => {
    if (expense.Amount < 1000) {
        Console.WriteLine($"Supervisor approved ${expense.Amount}");
        return true;
    } return false;
});
processor.AddHandler(expense => {
     if (expense.Amount < 5000) {
        Console.WriteLine($"Manager approved ${expense.Amount}");
        return true;
    } return false;
});
processor.AddHandler(expense => {
    Console.WriteLine($"Director approved ${expense.Amount}"); // Assume director handles anything remaining
    return true;
});

// Process expenses...
// processor.ProcessExpense(new Expense(500, "Supplies"));

2. Using Events

For even looser coupling:

public class ExpenseEventArgs : EventArgs
{
    public Expense Expense { get; set; }
    public bool Handled { get; set; }
}

public class ExpenseSubmitter
{
    public event EventHandler<ExpenseEventArgs> ApproveExpense;

    public void SubmitExpense(Expense expense)
    {
        var args = new ExpenseEventArgs { Expense = expense };
        ApproveExpense?.Invoke(this, args); // Raise the event

        if (!args.Handled)
            Console.WriteLine($"Expense for ${expense.Amount} was not approved.");
    }
}

// Usage:
var submitter = new ExpenseSubmitter();

// Subscribe handlers (order matters here for CoR logic)
submitter.ApproveExpense += (sender, args) => {
    if (!args.Handled && args.Expense.Amount < 1000) {
        Console.WriteLine($"Supervisor approved ${args.Expense.Amount}");
        args.Handled = true;
    }
};
submitter.ApproveExpense += (sender, args) => {
    if (!args.Handled && args.Expense.Amount < 5000) {
        Console.WriteLine($"Manager approved ${args.Expense.Amount}");
        args.Handled = true;
    }
};
submitter.ApproveExpense += (sender, args) => {
    if (!args.Handled && args.Expense.Amount >= 5000) { // Explicit check for Director level
        Console.WriteLine($"Director approved ${args.Expense.Amount}");
        args.Handled = true;
    }
};

// Submit expenses...
// submitter.SubmitExpense(new Expense(6000, "Server Rack"));

Pitfalls (Anti-Patterns) to Avoid

Even good patterns can be misused:

  1. Overly Long/Complex Chains: Can become slow and hard to debug. Keep chains focused.

  2. Unhandled Requests: Ensure a request doesn't fall off the end of the chain without consequence. Have a default handler or log unhandled requests.

  3. Using CoR for Simple Conditions: If a simple if-else or switch is clearer, use that. Don't over-engineer.


Pros and Cons

Advantages:

  • Decoupling: Sender doesn't know the receiver.

  • Flexibility: Easy to add/remove/reorder handlers.

  • Open/Closed Principle: Extend without modifying existing handlers.

  • Single Responsibility Principle: Handlers are focused.

Disadvantages:

  • Performance: Request might travel through many handlers.

  • Debugging: Can be tricky to trace which handler processed (or failed to process) a request in complex chains.

  • Guaranteed Handling: No guarantee a request will be handled unless you design the chain carefully (e.g., with a final default handler).


Conclusion: Is CoR Worth It?

Yes, absolutely—when used appropriately!

The Chain of Responsibility pattern helps create clean, maintainable, and flexible systems for processing requests sequentially through potential handlers. It shines in scenarios like middleware, UI event handling, and approval workflows.

Just remember:

  • Keep chains manageable.

  • Consider delegates or events for different coupling needs.

  • Ensure requests are eventually handled or logged.

Think of CoR like a well-drilled relay team, passing the responsibility smoothly. Use it thoughtfully, and it will help you build better, more scalable C# applications.


Want more articles like this? Visit https://developersvoice.com for weekly deep-dives.

0
Subscribe to my newsletter

Read articles from Sudhir Mangla directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sudhir Mangla
Sudhir Mangla