Chain of Responsibility in C#: Pipelines and Middleware
Chain of Responsibility in C# / .NET 10: build validation pipelines like ASP.NET Core middleware, with handlers that can forward or stop the chain.
Table of contents
- What problem does the Chain of Responsibility pattern solve?
- How does the textbook structure look in .NET 10?
- How does ASP.NET Core middleware use this pattern?
- When does Chain of Responsibility misfire?
- How does Chain of Responsibility compare to Decorator and Strategy?
- What does a real .NET 10 example look like?
- Where should you read next in this series?
The checkout endpoint must run six validations before charging a
card: the cart is non-empty, the customer is verified, the shipping
address is valid, the items are in stock, the discount code applies,
and the total is within fraud limits. The first version is one big
method with if (!check) return BadRequest(...) lines stacked on top
of each other. The fifth time someone needs to add a rule "between
discount and fraud", the method becomes unreadable.
The Chain of Responsibility pattern is the answer. Each rule becomes a handler with one job: examine the request, decide to pass it along or short-circuit with an error. The chain itself is just the order. Adding a new rule is one new handler plus one line in the registration. ASP.NET Core middleware is the most famous example of this pattern in .NET; the lesson here is that you can apply the same shape to any pipeline.
We continue with the e-commerce checkout: this article shows the pattern as a request-validation chain.
What problem does the Chain of Responsibility pattern solve?
The pattern earns its place when a request must flow through several handlers in order, each of which may handle it, modify it, or stop the chain. Three concrete shapes:
- Validation pipelines. Each rule short-circuits on failure; the request never reaches the action handler if any rule fails.
- Pre-processing pipelines. Decompress, decrypt, deserialise, authenticate. Each step does its work and forwards.
- Routing fallbacks. Try the primary handler; on
NotApplicable, try the secondary; onNotApplicableagain, try the default.
What is not a Chain problem: "every wrapper always forwards" — that is Decorator. "I want to fan out to many handlers and collect all responses" — that is closer to Observer or a flat list. Chain is specifically about linear ordering with optional short-circuit.
How does the textbook structure look in .NET 10?
A handler interface plus an explicit next:
public sealed record CheckoutRequest(string CartId, string CustomerId, decimal Total);
public sealed record CheckoutValidation(bool Ok, string? Error);
public delegate Task<CheckoutValidation> CheckoutHandler(
CheckoutRequest req, CancellationToken ct);
public interface ICheckoutRule
{
Task<CheckoutValidation> HandleAsync(
CheckoutRequest req, CheckoutHandler next, CancellationToken ct);
}
Each rule decides to call next or return early:
public sealed class CartNotEmptyRule : ICheckoutRule
{
private readonly ICartRepository _carts;
public CartNotEmptyRule(ICartRepository carts) => _carts = carts;
public async Task<CheckoutValidation> HandleAsync(
CheckoutRequest req, CheckoutHandler next, CancellationToken ct)
{
var cart = await _carts.GetAsync(req.CartId, ct);
if (cart is null || cart.Items.Count == 0)
return new(false, "Cart is empty");
return await next(req, ct);
}
}
public sealed class FraudLimitRule : ICheckoutRule
{
public Task<CheckoutValidation> HandleAsync(
CheckoutRequest req, CheckoutHandler next, CancellationToken ct)
=> req.Total > 100_000m
? Task.FromResult(new CheckoutValidation(false, "Above fraud limit"))
: next(req, ct);
}
The chain is built by folding the rules right to left, with a final handler that returns success:
public sealed class CheckoutValidator
{
private readonly CheckoutHandler _chain;
public CheckoutValidator(IEnumerable<ICheckoutRule> rules)
{
CheckoutHandler terminal = (_, __) => Task.FromResult(new CheckoutValidation(true, null));
_chain = rules.Reverse().Aggregate(terminal,
(next, rule) => (req, ct) => rule.HandleAsync(req, next, ct));
}
public Task<CheckoutValidation> ValidateAsync(CheckoutRequest req, CancellationToken ct)
=> _chain(req, ct);
}
The structural picture:
flowchart LR
Req[CheckoutRequest] --> R1[CartNotEmptyRule]
R1 -->|next| R2[CustomerVerifiedRule]
R2 -->|next| R3[InStockRule]
R3 -->|next| R4[FraudLimitRule]
R4 -->|next| Done[Success]
R1 -. short-circuit .-> Fail[Error: cart empty]
R3 -. short-circuit .-> Fail
R4 -. short-circuit .-> Fail
Each rule is a node. Most flow through; any one can stop the chain.
How does ASP.NET Core middleware use this pattern?
ASP.NET Core's pipeline is the same shape with framework-specific types:
app.Use(async (ctx, next) =>
{
if (string.IsNullOrEmpty(ctx.Request.Headers["X-Tenant"]))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsync("Missing tenant header");
return; // short-circuit
}
await next(); // forward
});
app.Use(async (ctx, next) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
await next();
Console.WriteLine($"{ctx.Request.Path}: {sw.ElapsedMilliseconds}ms");
});
app.MapControllers();
Each call to app.Use adds a handler. next is the rest of the
pipeline. Short-circuit by not calling next. Recognising
middleware as Chain of Responsibility is what tells you that
everything you know about middleware applies to your custom
pipelines too — and vice versa.
When does Chain of Responsibility misfire?
Three traps:
- Order is implicit and brittle. "FraudLimit must run after TotalCalculation" is a rule that lives only in the registration order. A new developer drops a rule in the wrong place and the fraud check no longer sees the discounted total. Document the order; consider explicit dependency declarations if it grows large.
- Handlers reach across the chain. A rule mutates a shared context to communicate with a later rule. The chain becomes a coupling chain. Pass an immutable record forward; if a rule needs to add information, return a new record with the addition.
- Errors swallowed silently. A handler returns a bare
falsewith no message; the caller sees "validation failed" with no detail. Always include actionable error info; logs are not enough.
How does Chain of Responsibility compare to Decorator and Strategy?
| Pattern | Always forwards? | Short-circuit? | Number of layers |
|---|---|---|---|
| Chain of Responsibility | No | Yes | Many, ordered |
| Decorator | Yes | No (or rare) | Many, ordered |
| Strategy | n/a | n/a | One (replace) |
The cleanest rule: if the wrapper might not call next, you
have Chain of Responsibility; if every wrapper always forwards, you
have Decorator. Strategy is in a different category — it picks
one algorithm rather than chaining many.
What does a real .NET 10 example look like?
Wire all the rules with DI and use the validator before charging:
public sealed record CheckoutRequest(string CartId, string CustomerId, decimal Total);
public sealed record CheckoutValidation(bool Ok, string? Error);
public delegate Task<CheckoutValidation> CheckoutHandler(CheckoutRequest req, CancellationToken ct);
public interface ICheckoutRule
{
Task<CheckoutValidation> HandleAsync(CheckoutRequest req, CheckoutHandler next, CancellationToken ct);
}
public sealed class CartNotEmptyRule : ICheckoutRule { /* see above */ }
public sealed class CustomerVerifiedRule : ICheckoutRule { /* ... */ }
public sealed class InStockRule : ICheckoutRule { /* ... */ }
public sealed class FraudLimitRule : ICheckoutRule { /* see above */ }
public sealed class CheckoutValidator
{
private readonly CheckoutHandler _chain;
public CheckoutValidator(IEnumerable<ICheckoutRule> rules)
{
CheckoutHandler terminal = (_, __) => Task.FromResult(new CheckoutValidation(true, null));
_chain = rules.Reverse().Aggregate(terminal, (next, rule) =>
(req, ct) => rule.HandleAsync(req, next, ct));
}
public Task<CheckoutValidation> ValidateAsync(CheckoutRequest req, CancellationToken ct)
=> _chain(req, ct);
}
// Composition root — order matters!
builder.Services.AddScoped<ICheckoutRule, CartNotEmptyRule>();
builder.Services.AddScoped<ICheckoutRule, CustomerVerifiedRule>();
builder.Services.AddScoped<ICheckoutRule, InStockRule>();
builder.Services.AddScoped<ICheckoutRule, FraudLimitRule>();
builder.Services.AddScoped<CheckoutValidator>();
// Caller
[ApiController, Route("checkout")]
public sealed class CheckoutController : ControllerBase
{
private readonly CheckoutValidator _validator;
private readonly ICheckoutService _checkout;
public CheckoutController(CheckoutValidator v, ICheckoutService c) => (_validator, _checkout) = (v, c);
[HttpPost]
public async Task<IActionResult> Post(CheckoutRequest req, CancellationToken ct)
{
var v = await _validator.ValidateAsync(req, ct);
if (!v.Ok) return BadRequest(v.Error);
var result = await _checkout.CheckoutAsync(new(req.CartId, "stripe"), ct);
return Ok(result);
}
}
// Test one rule in isolation
[Fact]
public async Task FraudLimit_blocks_above_threshold()
{
var sut = new FraudLimitRule();
var nextCalled = false;
CheckoutHandler next = (_, __) => { nextCalled = true; return Task.FromResult(new CheckoutValidation(true, null)); };
var result = await sut.HandleAsync(new("c1", "u1", 200_000m), next, default);
Assert.False(result.Ok);
Assert.False(nextCalled);
}
Each rule is testable in isolation. Adding a new rule is one class plus one DI line. The chain order is the registration order — make that order explicit in code review.
Where should you read next in this series?
- Previous: Proxy — last structural pattern.
- Next: Command — encapsulating a request as an object you can queue, undo, or replay.
- Cross-reference: Decorator — same shape, but every wrapper forwards.
- Cross-reference: Mediator — when the pattern fans out to multiple handlers selected by message type rather than running through them in order.
- Decision tree: How to choose the right design pattern.
A practical takeaway: once you can build an ASP.NET middleware pipeline, you can build any Chain of Responsibility. The framework gives you the patterns; the design pattern lets you reuse the same mental model anywhere a pipeline is the right shape.
Frequently asked questions
How is Chain of Responsibility different from Decorator?
Is ASP.NET Core middleware really Chain of Responsibility?
app.Use((ctx, next) => ...) adds a handler that can do work before the call, decide whether to call next, and do work after. That is the textbook pattern with the ASP.NET-specific RequestDelegate shape. Knowing the pattern transfers immediately: any pipeline you build for validations, audits, or transformations follows the same structure.When should you use Chain of Responsibility instead of a list of validators?
IEnumerable<IValidator> plus Where(v => !v.IsValid) is fine when validators are independent and you want to collect every error rather than stop at the first.How do you test individual handlers in a Chain of Responsibility?
next ran. The chain itself is wired in composition; integration test it once with realistic inputs.