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

"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:
Decoupling Sender and Receiver: The sender doesn't know who handles the request. This promotes flexibility.
Single Responsibility Principle (SRP): Each handler does one thing: handle the request or pass it on. Clean and concise.
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:
Overly Long/Complex Chains: Can become slow and hard to debug. Keep chains focused.
Unhandled Requests: Ensure a request doesn't fall off the end of the chain without consequence. Have a default handler or log unhandled requests.
Using CoR for Simple Conditions: If a simple
if-else
orswitch
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.
Subscribe to my newsletter
Read articles from Sudhir Mangla directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
