Welcome to the Behavioral patterns, the biggest group in the book. These are about how objects talk to each other and who’s responsible for what. We start with one you’ve almost certainly used if you’ve built an ASP.NET Core app. Chain of Responsibility lines up a series of handlers and passes a request along until one of them deals with it.
| Category | Behavioral |
| Smell fixed | S13: Checkout validation is one nested method |
| Code | patterns/behavioral/chain-of-responsibility |
The Scene
Four checkouts in a row get rejected, each for a different reason:
[052] checkout customer=cu-1 payment=card
checkout rejected: out of stock KETTLE-1L
[054] checkout customer=cu-2 payment=card
checkout rejected: address required
[056] checkout customer=cu-1 payment=card
checkout rejected: fraud score 88 exceeds threshold
[059] checkout customer=cu-3 payment=card
checkout rejected: total exceeds credit limit
That’s four validation rules: stock, address, fraud and credit limit, checked in that order, stopping at the first failure. Simple enough to describe. Let’s see how it’s written.
The Smell
All four rules live in one method, nested inside each other:
if (error is null)
{
if (customer.Address == "")
{
error = "address required";
}
else
{
var fraudScore = fraudScorer.Score(customer);
if (fraudScore > Config.FraudThreshold)
{
error = "fraud score " + fraudScore.ToString(CultureInfo.InvariantCulture) + " exceeds threshold";
}
else if (quote.Total > customer.CreditLimit)
{
error = "total exceeds credit limit";
}
}
}
The order of the checks is encoded in the shape of the if statements. Want to add a “blocked country” rule between address and fraud? You have to find the right level of nesting and slot it in without breaking the others. Want to run fraud before address? You’re restructuring the whole method. Want to test the credit-limit rule on its own? You have to get a customer past stock, address and fraud first.
Four rules is manageable. Ten would be miserable.
The Idea
Chain of Responsibility turns each rule into its own handler object. Each handler holds a reference to the next one. When a request arrives, a handler either deals with it (here, by rejecting it) or passes it down the line.
Think of a customer support line. Your call goes to the first level. If they can solve it, done. If not, they pass you to the next level, and so on. You don’t need to know who’s at level three. You just call the front of the line.
The Refactor
Here’s the base handler. It’s three lines of real logic, and I think it’s one of the nicest bits of code in the whole repository:
internal abstract class OrderValidationHandler(OrderValidationHandler? next)
{
public string? Handle(OrderValidationContext context)
{
return Validate(context) ?? next?.Handle(context);
}
protected abstract string? Validate(OrderValidationContext context);
}
Read that return line slowly: “Run my check. If it found a problem, return it. Otherwise, hand the request to the next handler, if there is one.” That’s the entire pattern in one line of C#.
Each rule is now a small class that only knows about itself. Here’s fraud:
internal sealed class FraudValidationHandler(FraudScorer fraudScorer, OrderValidationHandler? next)
: OrderValidationHandler(next)
{
protected override string? Validate(OrderValidationContext context)
{
var fraudScore = fraudScorer.Score(context.Customer);
return fraudScore > Config.FraudThreshold
? "fraud score " + fraudScore.ToString(CultureInfo.InvariantCulture) + " exceeds threshold"
: null;
}
}
And the validator just builds the chain once and calls the first link:
firstHandler = new StockValidationHandler(
inventory,
new AddressValidationHandler(
new FraudValidationHandler(
fraudScorer,
new CreditLimitValidationHandler(null))));
The order of validation is now visible. It’s right there in how the chain is built. Adding “blocked country” is a new class and one line in that list.
flowchart LR
Request[Checkout request] --> Stock[Stock handler]
Stock -->|ok| Address[Address handler]
Address -->|ok| Fraud[Fraud handler]
Fraud -->|ok| Credit[Credit limit handler]
Credit -->|ok| Valid[No error]
Stock -->|fail| Reject[Rejected]
Address -->|fail| Reject
Fraud -->|fail| Reject
Credit -->|fail| Reject
What It Cost
9 files: about 195 lines added and 41 removed. We went from one method to a base class, four handlers and a context object. More than half the added lines are tests, and that’s the real win: each handler can now be tested on its own, without first getting past the other three.
When Not to Use It
- The rules never change and there are only two or three. A short, flat list of
ifstatements is perfectly readable. - Every handler must always run. Chain of Responsibility stops at the first handler that deals with the request. If you need to collect all validation errors at once (like a form showing every problem), you want a simple list of validators that all run, not a chain.
- Nobody might handle it. If a request can fall off the end of the chain unhandled, make sure that’s a deliberate outcome. Here, it means “valid”.
You’ve seen this pattern in .NET already. ASP.NET Core middleware is a chain of responsibility: each piece of middleware can handle a request, or call next to pass it along.
Easily Confused With
Decorator. Both link objects together, each holding a reference to the next. The difference is what happens along the way. A decorator always passes the call through and adds something. A chain handler can stop the request and handle it completely. If links can short-circuit, it’s a chain.
Try It Yourself
dotnet build patterns/behavioral/chain-of-responsibility/ChainOfResponsibility.slnx
dotnet test patterns/behavioral/chain-of-responsibility/ChainOfResponsibility.slnx
dotnet run --project patterns/behavioral/chain-of-responsibility/src/CornerStore/CornerStore.csproj -- scenarios/trading-day.json
Exercise: write a MinimumOrderValidationHandler that rejects any quote under A$5, and slot it into the chain in OrderValidator. Count the files you touch. Then imagine making the same change to the nested if statements in the baseline.
The Chain of Responsibility README has the participant table and a sequence diagram. The full change is in DIFF.md.