Chain of Responsibility: ASP.NET Core Middleware, DelegatingHandler and Validation Chains
Chain of Responsibility passes a request along a chain of handlers until one handles it, breaking the coupling between sender and receiver; ASP.NET Core middleware and DelegatingHandler are short-circuiting forms of the pattern. A discount approval chain in Bookstore, the hidden business rule carried by IEnumerable registration order in DI, a validation chain that stops at the first error versus a pipeline that collects everything, and the trap of a request falling off the end of the chain.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Bookstore's catalog API (Application Programming Interface) has two middlewares and one endpoint: one measures the duration of every request, the other rejects requests without an X-Api-Key header. We run the app without opening a real port, using the TestServer from Microsoft.AspNetCore.TestHost, and send two requests: first without the header, then with it.
using Microsoft.AspNetCore.TestHost;
namespace Bookstore.Demos;
public static class MiddlewareDemo
{
public static async Task RunAsync()
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Logging.ClearProviders();
await using WebApplication app = builder.Build();
// Middleware 1: sees every request and also runs after the response
app.Use(async (context, next) =>
{
Console.WriteLine($"-> timing {context.Request.Path}");
await next(context);
Console.WriteLine($"<- timing {context.Response.StatusCode}");
});
// Middleware 2: ends the request here if there's no API key; next isn't called
app.Use(async (context, next) =>
{
if (!context.Request.Headers.ContainsKey("X-Api-Key"))
{
Console.WriteLine(" api-key: missing, short-circuit");
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
await next(context);
});
app.MapGet("/books/{isbn}", (string isbn) =>
{
Console.WriteLine($" endpoint: book {isbn}");
return Results.Ok(new { isbn, title = "Dune" });
});
await app.StartAsync();
HttpClient client = app.GetTestClient();
await client.GetAsync("/books/978-0441013593");
Console.WriteLine();
client.DefaultRequestHeaders.Add("X-Api-Key", "demo");
await client.GetAsync("/books/978-0441013593");
}
}
Which lines are printed for the first request, and in what order? Does the "<- timing" line appear for the first request too, and if so, with which status code?
The One That Handles It Stops, the Others Pass It On
-> timing /books/978-0441013593
api-key: missing, short-circuit
<- timing 401
-> timing /books/978-0441013593
endpoint: book 978-0441013593
<- timing 200
The endpoint never ran for the first request. The API key middleware handled the request, wrote 401 and didn't call next; in the words of the ASP.NET Core documentation, it short-circuited the request. The timing middleware still ran on the way back and saw the 401. For the second request the API key middleware said "this isn't my job" and passed the request on, and it reached the endpoint.
This behavior has a name: Chain of Responsibility. The ASP.NET Core request pipeline is the best-known example of this pattern in .NET. In this post we'll cover the pattern's definition in GoF, a discount approval chain in Bookstore that runs from the manager up to the finance director, the hidden meaning registration order carries when you build the same chain with DI (Dependency Injection), HttpClient's DelegatingHandler chain and a validation chain that stops at the first error. For middleware itself, see the "Middleware Pipeline: Ordering, Use, Run, Map and Your Own Middleware" post in the ASP.NET Core Web API series; here we focus on the pattern side.
The GoF Definition: Passing a Request Along Until It's Handled
Chain of Responsibility is one of the behavioral patterns in the GoF (Gang of Four: Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides' 1994 book Design Patterns: Elements of Reusable Object-Oriented Software) catalog. Its intent, in my own words: give more than one object a chance to handle a request in order to break the coupling between the sender of a request and the object that will handle it; chain those objects together and pass the request along the chain until one of them handles it.
There are two roles. The Handler defines an interface for handling requests and optionally holds a reference to the next handler (the successor, in GoF's terms). A ConcreteHandler handles the requests it can and forwards the ones it can't to its successor. The client hands the request to the head of the chain and doesn't know who will handle it. The book's example is context-sensitive help in a user interface: when the user asks for help on a button, the request goes first to the button, then, if the button can't answer, to the dialog containing it, and then to the application.
The three consequences GoF lists are the skeleton of this post too. Coupling is reduced: the sender doesn't know who the receiver is. Flexibility increases: adding, removing or reordering handlers in the chain doesn't affect the client. And the one most often forgotten: receipt isn't guaranteed. If no handler takes responsibility, the request falls off the end of the chain.
One more distinction is needed. In the classic form GoF describes, only one of the handlers handles the request; the others just pass it on. ASP.NET Core middleware is an extended form of this: every middleware can process the request both before passing it on and after the response comes back, and most of the time they all do something. This second form is also known as Pipeline or Pipes and Filters; we'll cover it separately in the upcoming "Code-Level Patterns Beyond GoF" post of this series. The question that separates the two is simple: when one of the handlers handles the request, does the chain stop?
Your Own Chain: Discount Approval
Bookstore's corporate sales team gives discounts on large orders. The team lead can approve up to 5 percent, the sales director up to 20 percent; the finance director can go up to 40 percent, but only on orders of 1000 EUR or more. Anything above that is beyond anyone's authority.
namespace Bookstore.Discounts;
public sealed record DiscountRequest(string Customer, decimal OrderTotal, decimal Percent);
// Handler: either handles the request itself or passes it to the next one in the chain
public abstract class Approver
{
private Approver? _next;
protected abstract string Title { get; }
protected abstract bool CanApprove(DiscountRequest request);
public Approver SetNext(Approver next)
{
_next = next;
return next;
}
public string Handle(DiscountRequest request)
{
if (CanApprove(request))
return $"approved by {Title}";
return _next is not null
? _next.Handle(request)
: $"rejected: nobody can approve {request.Percent}% on {request.OrderTotal} EUR";
}
}
public sealed class TeamLead : Approver
{
protected override string Title => "team lead";
protected override bool CanApprove(DiscountRequest request) => request.Percent <= 5;
}
public sealed class SalesDirector : Approver
{
protected override string Title => "sales director";
protected override bool CanApprove(DiscountRequest request) => request.Percent <= 20;
}
// The finance director grants a high discount only on a corporate-sized order
public sealed class FinanceDirector : Approver
{
protected override string Title => "finance director";
protected override bool CanApprove(DiscountRequest request) => request.Percent <= 40 && request.OrderTotal >= 1000;
}
public static class ClassicChainDemo
{
public static void Run()
{
Approver chain = new TeamLead();
chain.SetNext(new SalesDirector()).SetNext(new FinanceDirector());
DiscountRequest[] requests =
[
new("Ada", 300m, 5m),
new("Alan", 300m, 15m),
new("Grace", 2400m, 35m),
new("Linus", 300m, 35m),
new("Barbara", 5000m, 60m)
];
foreach (DiscountRequest request in requests)
Console.WriteLine($"{request.Customer,-8} {request.Percent,3}% of {request.OrderTotal,5} EUR -> {chain.Handle(request)}");
}
}
Ada 5% of 300 EUR -> approved by team lead
Alan 15% of 300 EUR -> approved by sales director
Grace 35% of 2400 EUR -> approved by finance director
Linus 35% of 300 EUR -> rejected: nobody can approve 35% on 300 EUR
Barbara 60% of 5000 EUR -> rejected: nobody can approve 60% on 5000 EUR
The sales screen only calls chain.Handle(request); it doesn't know the approval hierarchy, the limits, or who steps in under which condition. When a new level arrives tomorrow ("regional manager up to 10 percent"), a new Approver class will be written and added to the chain with one line; the screen's code won't be touched. Look at the last two lines too: on a 300 EUR order nobody, the finance director included, can approve 35 percent, and the request falls off the end of the chain. What deliberately produces this result is the last branch in Handle; without that branch the code would return null or pass through doing nothing. What happens at the end of the chain should be part of the chain's design.
Also notice a small difference from the structure GoF suggests: SetNext returns the handler it was given, so the chain can be built in a single fluent expression. Still, this classic form has a weakness: understanding the chain's structure requires the reader to follow the SetNext calls, and because every handler holds a reference to the next, that link has to be set up when testing handlers too.
Building the Chain with DI: IEnumerable<T> and Registration Order
The more common form in .NET is a version where handlers don't know each other at all: every approver implements the same interface and is registered in the DI container under the same service type, and the chain takes them as an IEnumerable<IDiscountApprover> and picks the first one that says "yes". The successor reference disappears, and the order moves into the container.
namespace Bookstore.Discounts.Di;
public interface IDiscountApprover
{
string Title { get; }
bool CanApprove(DiscountRequest request);
}
public sealed class TeamLeadApprover : IDiscountApprover
{
public string Title => "team lead";
public bool CanApprove(DiscountRequest request) => request.Percent <= 5;
}
public sealed class SalesDirectorApprover : IDiscountApprover
{
public string Title => "sales director";
public bool CanApprove(DiscountRequest request) => request.Percent <= 20;
}
public sealed class FinanceDirectorApprover : IDiscountApprover
{
public string Title => "finance director";
public bool CanApprove(DiscountRequest request) => request.Percent <= 40 && request.OrderTotal >= 1000;
}
// The order of the chain is the order of DI registration
public sealed class DiscountApprovalChain(IEnumerable<IDiscountApprover> approvers)
{
public string Handle(DiscountRequest request) =>
approvers.FirstOrDefault(approver => approver.CanApprove(request)) is { } approver
? $"approved by {approver.Title}"
: $"rejected: nobody can approve {request.Percent}% on {request.OrderTotal} EUR";
}
public static class DiChainDemo
{
public static void Run()
{
ServiceCollection services = new();
services.AddSingleton<IDiscountApprover, TeamLeadApprover>();
services.AddSingleton<IDiscountApprover, SalesDirectorApprover>();
services.AddSingleton<IDiscountApprover, FinanceDirectorApprover>();
services.AddSingleton<DiscountApprovalChain>();
Print("registered team lead first", services);
// Someone registered the finance director first because "it's the most important"
ServiceCollection reordered = new();
reordered.AddSingleton<IDiscountApprover, FinanceDirectorApprover>();
reordered.AddSingleton<IDiscountApprover, TeamLeadApprover>();
reordered.AddSingleton<IDiscountApprover, SalesDirectorApprover>();
reordered.AddSingleton<DiscountApprovalChain>();
Print("registered finance director first", reordered);
}
private static void Print(string title, ServiceCollection services)
{
using ServiceProvider provider = services.BuildServiceProvider();
DiscountApprovalChain chain = provider.GetRequiredService<DiscountApprovalChain>();
Console.WriteLine(title);
foreach (DiscountRequest request in (DiscountRequest[])[new("Grace", 2400m, 5m), new("Grace", 2400m, 15m)])
Console.WriteLine($" {request.Percent,3}% of {request.OrderTotal,5} EUR -> {chain.Handle(request)}");
}
}
registered team lead first
5% of 2400 EUR -> approved by team lead
15% of 2400 EUR -> approved by sales director
registered finance director first
5% of 2400 EUR -> approved by finance director
15% of 2400 EUR -> approved by finance director
Not a single line of the code changed; only the registration order in Program.cs did, and a 5 percent discount on a 2400 EUR order now lands in the finance director's approval queue. The compiler said nothing, and the tests (if each approver is tested separately) are green. Microsoft.Extensions.DependencyInjection provides implementations registered under the same service type as an IEnumerable<T> in registration order; that isn't an accident, it's documented behavior. But nothing in the code says that this order carries a business rule.
There are two remedies. The first is to make the order explicit: add an int Order property to the interface and sort with OrderBy in the chain's constructor; now the order belongs to the approver itself, not to how the registration lines happen to be arranged. The second is to make the order irrelevant: if each approver's condition is written to be mutually exclusive (team lead 0–5, director 5–20, finance 20–40), which one is asked first doesn't change the result. Whichever you choose, verify the whole chain with a test: a "5 percent, 2400 EUR, the team lead approves" test would have caught the reordering above immediately. For DI lifetimes and registration mechanics, see the "Dependency Injection Basics" post in the Advanced C# series.
DelegatingHandler: HttpClient's Outgoing Request Chain
If middleware is the chain for incoming requests, DelegatingHandler is the chain for outgoing ones. Every handler holds a reference to the next handler called InnerHandler (GoF's successor) and passes the request to it with base.SendAsync; at the very end sits the primary handler that does the real network work (SocketsHttpHandler by default). When building the chain with IHttpClientFactory, the order is again the registration order: the first handler added runs outermost.
using System.Net;
namespace Bookstore.Demos;
// Adds a correlation id to the request, then passes it along the chain
public sealed class CorrelationIdHandler : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
request.Headers.Add("X-Correlation-Id", "c-42");
Console.WriteLine("-> correlation");
HttpResponseMessage response = await base.SendAsync(request, ct);
Console.WriteLine("<- correlation");
return response;
}
}
// If it sees the same GET a second time, it answers without going to the network at all
public sealed class CachingHandler : DelegatingHandler
{
private readonly Dictionary<string, string> _cache = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
string key = request.RequestUri!.ToString();
if (_cache.TryGetValue(key, out string? body))
{
Console.WriteLine(" cache: hit, short-circuit");
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) };
}
Console.WriteLine(" cache: miss");
HttpResponseMessage response = await base.SendAsync(request, ct);
_cache[key] = await response.Content.ReadAsStringAsync(ct);
return response;
}
}
// The end of the chain: SocketsHttpHandler in a real app, here a stub that doesn't touch the network
public sealed class CatalogStubHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Console.WriteLine($" network: {request.RequestUri!.AbsolutePath} (correlation {request.Headers.GetValues("X-Correlation-Id").Single()})");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{"title":"Dune"}""") });
}
}
public static class HandlerDemo
{
public static async Task RunAsync()
{
ServiceCollection services = new();
services.AddTransient<CorrelationIdHandler>();
services.AddTransient<CachingHandler>();
// The first handler added runs outermost
services.AddHttpClient("catalog", client => client.BaseAddress = new Uri("https://catalog.example/"))
.AddHttpMessageHandler<CorrelationIdHandler>()
.AddHttpMessageHandler<CachingHandler>()
.ConfigurePrimaryHttpMessageHandler(() => new CatalogStubHandler());
using ServiceProvider provider = services.BuildServiceProvider();
HttpClient client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("catalog");
await client.GetStringAsync("books/978-0441013593");
Console.WriteLine();
await client.GetStringAsync("books/978-0441013593");
}
}
-> correlation
cache: miss
network: /books/978-0441013593 (correlation c-42)
<- correlation
-> correlation
cache: hit, short-circuit
<- correlation
On the second request the cache handler handled the request and didn't pass it to the rest of the chain; the network was never touched. The correlation handler, on the other hand, ran on both requests, both on the way out and on the way back. It's exactly the same as the API key check in the middleware, just in the opposite direction. Microsoft's documentation on HTTP (Hypertext Transfer Protocol) requests also calls this chain "outgoing request middleware". In this series' Decorator post we saw the same DelegatingHandler as a decorator; both are correct. Looking at a single handler, it's a decorator: it wraps the same abstraction and adds behavior. Looking at the chain as a whole, and especially at the handler that short-circuits, it's Chain of Responsibility.
A Validation Chain: Stop at the First Error or Collect Them All?
You don't always need a class hierarchy to build a Chain of Responsibility. In C# a handler can be a Func that says "either answer or pass with null", and the chain an array of those functions:
namespace Bookstore.Orders;
public sealed record OrderLine(string Isbn, int Quantity);
public sealed record OrderDraft(string Customer, string ShippingCountry, IReadOnlyList<OrderLine> Lines);
public static class OrderRules
{
// Each rule either returns an error or says "no problem here, move on" with null
private static readonly Func<OrderDraft, string?>[] Rules =
[
order => order.Lines.Count == 0 ? "order has no lines" : null,
order => order.Lines.Any(line => line.Quantity <= 0) ? "quantities must be positive" : null,
order => order.ShippingCountry is "TR" or "DE" or "NL" ? null : $"no shipping to {order.ShippingCountry}",
order => order.Lines.Sum(line => line.Quantity) > 50 ? "bulk orders go through sales" : null
];
// Chain of Responsibility: stops at the first error (Select uses deferred execution)
public static string? FirstError(OrderDraft order) =>
Rules.Select(rule => rule(order)).FirstOrDefault(error => error is not null);
// Pipeline: every rule runs, every error is collected
public static IReadOnlyList<string> AllErrors(OrderDraft order) =>
[.. Rules.Select(rule => rule(order)).OfType<string>()];
}
public static class ValidationChainDemo
{
public static void Run()
{
OrderDraft draft = new("Ada", "US", [new("978-0441013593", 0), new("978-0547928227", 60)]);
Console.WriteLine($"first error: {OrderRules.FirstError(draft)}");
Console.WriteLine($"all errors: {string.Join(" | ", OrderRules.AllErrors(draft))}");
Console.WriteLine($"valid order: {OrderRules.FirstError(new("Alan", "DE", [new("978-0441013593", 2)])) ?? "no errors"}");
}
}
first error: quantities must be positive
all errors: quantities must be positive | no shipping to US | bulk orders go through sales
valid order: no errors
FirstError is classic Chain of Responsibility: because Select uses deferred execution, the remaining rules never run once FirstOrDefault finds the first error. If an expensive rule (a call to the stock service, a database query) sits at the end of the chain, the difference is measurable. AllErrors, with the same rules, is a pipeline: they all run and all their results are collected. Which one you pick is a UX (User Experience) decision: if you want to show the user every error on a form at once, a pipeline; for an API that says "the first obstacle is enough", or for expensive checks, a chain. The CascadeMode setting in libraries such as FluentValidation lets you choose between exactly these two behaviors.
Common Pitfalls
A request that falls off the end of the chain. A request no handler takes on is silently lost unless that case is explicitly designed. In the classic form, what will the last handler of the chain return; in the DI form, what happens when FirstOrDefault gives null? ASP.NET Core answers this question itself: if no endpoint matches, the end of the pipeline returns 404. Your own chain should have a "terminal handler" or an explicit rejection too.
Hidden dependency on order. Writing UseAuthentication after UseAuthorization in middleware, putting the retry handler inside the cache among DelegatingHandlers, reordering approvers in DI; all of these produce code that compiles but behaves wrongly. If the order carries a rule, either express it explicitly (Order, a single named setup method) or test the whole chain.
Keeping state in a handler. The CachingHandler in the example keeps its cache in its own field, and that's why the demo works. But IHttpClientFactory pools handlers and by default renews them every two minutes; that cache gets reset silently along with the handler, and since the same handler instance can be used by several requests at the same time, the Dictionary isn't thread-safe either. For a real cache, inject IMemoryCache or HybridCache.
Debugging a long chain. In a pipeline with twelve middlewares, the answer to "where did this 401 come from?" isn't in the stack trace but in the order. Having every short-circuiting handler log why it stopped brings that question down to minutes.
Cycles. In the classic form, setup code that writes a.SetNext(b); b.SetNext(a); takes the process down with a StackOverflowException on the first unhandled request. The DI form, which iterates over a collection instead of following successor references, eliminates this class of bug from the start.
When Not to Use It
If the number of handlers is fixed and small, their order will never change and each handles a different kind of request, a switch expression is more readable: request.Percent switch { <= 5 => ..., <= 20 => ..., _ => ... } shows the whole rule on one screen. The value of Chain of Responsibility shows up where handlers can be added and removed independently, where different teams own different handlers, or where the chain is shaped by configuration. If every request has exactly one receiver and that receiver is known at compile time, putting a chain in between only breaks go-to-definition.
A Small Experiment
★☆☆ Add a RegionalManager to the discount chain who approves up to 10 percent. How many files did you have to touch in the classic form and in the DI form?
★★☆ Add int Order to IDiscountApprover and make DiscountApprovalChain independent of registration order. Then write a single xUnit test that would catch the "finance director first" scenario.
★★★ Add a third middleware to the middleware example that returns 403 and logs it when the X-Api-Key value is invalid. In which position does the timing middleware see the 403, and in which doesn't it? Research in which cases a middleware can no longer write to the response after next (Response.HasStarted).
In the next post we move on to the pattern that turns a request into an object: Command. We'll build an undo/redo stack in Bookstore's catalog editor, serialize commands to JSON (JavaScript Object Notation) and run them in the background through a queue, and discuss whether the "command" in CQRS (Command Query Responsibility Segregation) is the same thing as GoF's command.