Chain of Responsibility Pattern Real-World Example in C#: Complete Implementation
Reading about design patterns in the abstract only gets you so far. A chain of responsibility pattern real world example in C# with real infrastructure concerns is what cements the concept into something you can actually use at work. This article walks through a complete HTTP request processing pipeline -- the kind you'd build for an API gateway -- where each handler in the chain decides whether a request should proceed or get rejected. By the end, you'll have a fully implemented chain covering rate limiting, authentication, authorization, validation, and routing -- all configurable through dependency injection.
The Problem: HTTP Request Processing Pipeline
Imagine you're building an API gateway that sits in front of several backend services. Every incoming request needs to pass through several stages before it reaches its destination: rate limiting checks the caller's quota, authentication verifies the JWT token, and authorization confirms the right permissions.
After those security gates, the request still needs body validation and routing to the correct backend service. Without a structured approach, this logic collapses into a massive nested if/else block:
public sealed class MonolithicGateway
{
public async Task<GatewayResponse> ProcessAsync(
GatewayRequest request)
{
if (IsRateLimited(request.ClientId))
return new GatewayResponse(429, "Rate limit exceeded");
if (!IsValidToken(request.AuthToken))
return new GatewayResponse(401, "Invalid token");
if (!HasPermission(request.UserId, request.Resource))
return new GatewayResponse(403, "Forbidden");
if (!IsValidPayload(request.Body))
return new GatewayResponse(400, "Invalid request body");
return await RouteToServiceAsync(request);
}
}
This works for five checks. But real API gateways accumulate dozens of concerns over time -- logging, tracing, CORS, request transformation. Each new concern makes the method longer and harder to test. The chain of responsibility pattern solves this by turning each concern into an independent handler that can be composed, reordered, and tested in isolation.
Designing the Handler Base
The foundation of our chain of responsibility pattern real world example in C# is a pair of types: a request/response model and an abstract handler that defines the chaining mechanism. Let's start with the request and response:
namespace Gateway.Pipeline;
public sealed class GatewayRequest
{
public required string ClientId { get; init; }
public required string AuthToken { get; init; }
public required string UserId { get; init; }
public required string Resource { get; init; }
public required string HttpMethod { get; init; }
public required string Path { get; init; }
public string? Body { get; init; }
}
public sealed class GatewayResponse
{
public int StatusCode { get; init; }
public string Message { get; init; }
public GatewayResponse(int statusCode, string message)
{
StatusCode = statusCode;
Message = message;
}
}
Now for the abstract handler that all concrete implementations will extend:
namespace Gateway.Pipeline;
public abstract class RequestHandler
{
private RequestHandler? _next;
public RequestHandler SetNext(RequestHandler next)
{
_next = next;
return next;
}
public virtual async Task<GatewayResponse> HandleAsync(
GatewayRequest request)
{
if (_next is not null)
{
return await _next.HandleAsync(request);
}
return new GatewayResponse(
500,
"No handler processed the request");
}
}
The SetNext method wires handlers together and returns the next handler for fluent chaining. The HandleAsync method provides default pass-through behavior -- if a concrete handler doesn't short-circuit, it delegates to the next handler. If you've worked with the strategy design pattern, you'll notice a similar emphasis on isolating behavior behind a common contract -- except here, handlers are linked sequentially rather than swapped interchangeably.
Implementing the Handler Chain
With the base class in place, let's build five concrete handlers. Each one examines the request, either rejects it or passes it along.
RateLimitHandler
The first handler checks whether the client has exceeded their allowed request rate:
using System.Collections.Concurrent;
namespace Gateway.Pipeline.Handlers;
public sealed class RateLimitHandler : RequestHandler
{
private readonly ConcurrentDictionary<string, ClientRateInfo>
_clients = new();
private readonly int _maxRequests;
private readonly TimeSpan _window;
public RateLimitHandler(
int maxRequests = 100,
TimeSpan? window = null)
{
_maxRequests = maxRequests;
_window = window ?? TimeSpan.FromMinutes(1);
}
public override async Task<GatewayResponse> HandleAsync(
GatewayRequest request)
{
var info = _clients.GetOrAdd(
request.ClientId,
_ => new ClientRateInfo());
if (info.IsExpired(_window))
{
info.Reset();
}
info.IncrementCount();
if (info.Count > _maxRequests)
{
return new GatewayResponse(
429,
"Rate limit exceeded");
}
return await base.HandleAsync(request);
}
}
public sealed class ClientRateInfo
{
public int Count { get; private set; }
public DateTimeOffset WindowStart { get; private set; }
= DateTimeOffset.UtcNow;
public bool IsExpired(TimeSpan window) =>
DateTimeOffset.UtcNow - WindowStart > window;
public void Reset()
{
Count = 0;
WindowStart = DateTimeOffset.UtcNow;
}
public void IncrementCount() => Count++;
}
When the client is within their limit, the handler calls base.HandleAsync(request) to pass the request down the chain. When the limit is exceeded, it short-circuits with a 429 response.
AuthenticationHandler
The authentication handler validates the JWT token attached to the request:
namespace Gateway.Pipeline.Handlers;
public sealed class AuthenticationHandler : RequestHandler
{
private readonly ITokenValidator _tokenValidator;
public AuthenticationHandler(
ITokenValidator tokenValidator)
{
_tokenValidator = tokenValidator;
}
public override async Task<GatewayResponse> HandleAsync(
GatewayRequest request)
{
if (string.IsNullOrEmpty(request.AuthToken))
{
return new GatewayResponse(
401,
"Missing authentication token");
}
var isValid = await _tokenValidator
.ValidateAsync(request.AuthToken);
if (!isValid)
{
return new GatewayResponse(
401,
"Invalid or expired token");
}
return await base.HandleAsync(request);
}
}
public interface ITokenValidator
{
Task<bool> ValidateAsync(string token);
}
The ITokenValidator interface keeps the handler testable -- this is the same inversion of control principle that makes the whole chain composable.
AuthorizationHandler
The authorization handler checks whether the user has permission to access the requested resource:
namespace Gateway.Pipeline.Handlers;
public sealed class AuthorizationHandler : RequestHandler
{
private readonly IAuthorizationService _authService;
public AuthorizationHandler(
IAuthorizationService authService)
{
_authService = authService;
}
public override async Task<GatewayResponse> HandleAsync(
GatewayRequest request)
{
var isAuthorized = await _authService
.IsAuthorizedAsync(
request.UserId,
request.Resource,
request.HttpMethod);
if (!isAuthorized)
{
return new GatewayResponse(
403,
"Insufficient permissions");
}
return await base.HandleAsync(request);
}
}
public interface IAuthorizationService
{
Task<bool> IsAuthorizedAsync(
string userId,
string resource,
string httpMethod);
}
The pattern is consistent across every handler: check a condition, return an error response if it fails, or delegate to the next handler.
InputValidationHandler
The validation handler examines the request body for write operations and passes read operations through:
namespace Gateway.Pipeline.Handlers;
public sealed class InputValidationHandler : RequestHandler
{
private readonly IRequestBodyValidator _bodyValidator;
public InputValidationHandler(
IRequestBodyValidator bodyValidator)
{
_bodyValidator = bodyValidator;
}
public override async Task<GatewayResponse> HandleAsync(
GatewayRequest request)
{
var requiresBody = request.HttpMethod is
"POST" or "PUT" or "PATCH";
if (requiresBody &&
string.IsNullOrWhiteSpace(request.Body))
{
return new GatewayResponse(
400,
"Request body is required");
}
if (requiresBody)
{
var validation = await _bodyValidator
.ValidateAsync(
request.Path,
request.Body!);
if (!validation.IsValid)
{
return new GatewayResponse(
400,
$"Validation failed: " +
$"{validation.ErrorMessage}");
}
}
return await base.HandleAsync(request);
}
}
public interface IRequestBodyValidator
{
Task<ValidationResult> ValidateAsync(
string path,
string body);
}
public sealed class ValidationResult
{
public bool IsValid { get; init; }
public string? ErrorMessage { get; init; }
public static ValidationResult Success() =>
new() { IsValid = true };
public static ValidationResult Failure(string error) =>
new() { IsValid = false, ErrorMessage = error };
}
Not every handler needs to act on every request -- GET and DELETE requests skip body validation entirely.
RequestRoutingHandler
The final handler routes the validated request to the appropriate backend service:
namespace Gateway.Pipeline.Handlers;
public sealed class RequestRoutingHandler : RequestHandler
{
private readonly IServiceRouter _router;
public RequestRoutingHandler(IServiceRouter router)
{
_router = router;
}
public override async Task<GatewayResponse> HandleAsync(
GatewayRequest request)
{
var result = await _router.RouteAsync(
request.Path,
request.HttpMethod,
request.Body);
return new GatewayResponse(
result.StatusCode,
result.ResponseBody);
}
}
public interface IServiceRouter
{
Task<RouteResult> RouteAsync(
string path,
string httpMethod,
string? body);
}
public sealed class RouteResult
{
public required int StatusCode { get; init; }
public required string ResponseBody { get; init; }
}
This handler intentionally does not call base.HandleAsync -- it's the end of the chain. If you're familiar with the composite design pattern, the distinction is worth noting: composite delegates recursively to children, while chain of responsibility delegates along a flat sequence with explicit short-circuiting.
Building and Configuring the Chain
Having five handlers is great, but you need a clean way to assemble them. A factory class combined with IServiceCollection registration gives you a flexible, configuration-driven pipeline:
using Microsoft.Extensions.DependencyInjection;
namespace Gateway.Pipeline;
public static class PipelineServiceExtensions
{
public static IServiceCollection AddGatewayPipeline(
this IServiceCollection services)
{
services.AddSingleton<RateLimitHandler>();
services.AddSingleton<AuthenticationHandler>();
services.AddSingleton<AuthorizationHandler>();
services.AddSingleton<InputValidationHandler>();
services.AddSingleton<RequestRoutingHandler>();
services.AddSingleton<IPipelineFactory,
PipelineFactory>();
return services;
}
}
public interface IPipelineFactory
{
RequestHandler CreatePipeline();
}
public sealed class PipelineFactory : IPipelineFactory
{
private readonly IServiceProvider _serviceProvider;
public PipelineFactory(
IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public RequestHandler CreatePipeline()
{
var handlers = new RequestHandler[]
{
_serviceProvider
.GetRequiredService<RateLimitHandler>(),
_serviceProvider
.GetRequiredService<AuthenticationHandler>(),
_serviceProvider
.GetRequiredService<AuthorizationHandler>(),
_serviceProvider
.GetRequiredService<InputValidationHandler>(),
_serviceProvider
.GetRequiredService<RequestRoutingHandler>(),
};
for (int i = 0; i < handlers.Length - 1; i++)
{
handlers[i].SetNext(handlers[i + 1]);
}
return handlers[0];
}
}
The factory resolves each handler from the DI container and chains them in a loop. The order in the array is the order of execution -- if you need to insert a new handler, you add one line to the array. This separation between construction and execution is the same principle behind inversion of control. Using the factory from application code is straightforward:
var pipeline = pipelineFactory.CreatePipeline();
var response = await pipeline.HandleAsync(request);
Adding Resilience and Observability
A chain of responsibility pattern real world example in C# isn't complete without addressing production concerns. Let's enhance the pipeline with structured logging, metrics, and circuit breaker support.
The cleanest approach is an observability-aware base handler that wraps each handler with cross-cutting concerns:
using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace Gateway.Pipeline;
public abstract class ObservableHandler : RequestHandler
{
private readonly ILogger _logger;
private readonly string _handlerName;
protected ObservableHandler(ILogger logger)
{
_logger = logger;
_handlerName = GetType().Name;
}
public override async Task<GatewayResponse> HandleAsync(
GatewayRequest request)
{
var stopwatch = Stopwatch.StartNew();
_logger.LogInformation(
"Handler {HandlerName} processing " +
"request for {Path}",
_handlerName,
request.Path);
try
{
var response = await ProcessAsync(request);
stopwatch.Stop();
_logger.LogInformation(
"Handler {HandlerName} completed " +
"in {ElapsedMs}ms with status {StatusCode}",
_handlerName,
stopwatch.ElapsedMilliseconds,
response.StatusCode);
return response;
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(
ex,
"Handler {HandlerName} failed " +
"after {ElapsedMs}ms",
_handlerName,
stopwatch.ElapsedMilliseconds);
return new GatewayResponse(
500,
$"Internal error in {_handlerName}");
}
}
protected abstract Task<GatewayResponse> ProcessAsync(
GatewayRequest request);
}
This base class wraps every handler execution with timing, structured logging, and exception handling. Concrete handlers override ProcessAsync instead of HandleAsync -- the observable base adds instrumentation transparently. If you've worked with the observer design pattern, the concept is similar: external observers react to handler execution without modifying core logic.
For circuit breaker support, create a state object that any handler calling external services can check:
namespace Gateway.Pipeline.Handlers;
public sealed class CircuitBreakerState
{
private int _failureCount;
private DateTimeOffset _lastFailure;
public int FailureThreshold { get; init; } = 5;
public TimeSpan RecoveryWindow { get; init; }
= TimeSpan.FromSeconds(30);
public bool IsOpen =>
_failureCount >= FailureThreshold &&
DateTimeOffset.UtcNow - _lastFailure
< RecoveryWindow;
public void RecordFailure()
{
_failureCount++;
_lastFailure = DateTimeOffset.UtcNow;
}
public void Reset() => _failureCount = 0;
}
This state object can be injected into any handler that calls external services. When the circuit is open, the handler returns a 503 immediately instead of forwarding the request to a service that's likely down.
Testing the Complete Pipeline
Testing is where the chain of responsibility pattern shines. Because each handler is a separate class with injected dependencies, you can write focused tests for individual handlers and the full chain.
First, test helpers and stub dependencies:
using Xunit;
namespace Gateway.Pipeline.Tests;
public static class TestRequestFactory
{
public static GatewayRequest CreateValid() =>
new()
{
ClientId = "client-1",
AuthToken = "valid-token",
UserId = "user-1",
Resource = "/api/orders",
HttpMethod = "GET",
Path = "/api/orders",
Body = null,
};
}
public sealed class StubTokenValidator : ITokenValidator
{
private readonly bool _isValid;
public StubTokenValidator(bool isValid) =>
_isValid = isValid;
public Task<bool> ValidateAsync(string token) =>
Task.FromResult(_isValid);
}
public sealed class StubAuthorizationService
: IAuthorizationService
{
private readonly bool _isAuthorized;
public StubAuthorizationService(bool isAuthorized) =>
_isAuthorized = isAuthorized;
public Task<bool> IsAuthorizedAsync(
string userId,
string resource,
string httpMethod) =>
Task.FromResult(_isAuthorized);
}
public sealed class StubServiceRouter : IServiceRouter
{
public Task<RouteResult> RouteAsync(
string path,
string httpMethod,
string? body) =>
Task.FromResult(new RouteResult
{
StatusCode = 200,
ResponseBody = "OK",
});
}
public sealed class StubRequestBodyValidator
: IRequestBodyValidator
{
private readonly bool _isValid;
public StubRequestBodyValidator(bool isValid) =>
_isValid = isValid;
public Task<ValidationResult> ValidateAsync(
string path,
string body) =>
Task.FromResult(
_isValid
? ValidationResult.Success()
: ValidationResult.Failure(
"Invalid body"));
}
}
Now, individual handler tests:
namespace Gateway.Pipeline.Tests;
public sealed class AuthenticationHandlerTests
{
[Fact]
public async Task HandleAsync_MissingToken_Returns401()
{
var handler = new AuthenticationHandler(
new StubTokenValidator(true));
var request = new GatewayRequest
{
ClientId = "c1",
AuthToken = string.Empty,
UserId = "u1",
Resource = "/api/orders",
HttpMethod = "GET",
Path = "/api/orders",
};
var response = await handler.HandleAsync(request);
Assert.Equal(401, response.StatusCode);
}
[Fact]
public async Task HandleAsync_ValidToken_DelegatesToNext()
{
var handler = new AuthenticationHandler(
new StubTokenValidator(true));
var routing = new RequestRoutingHandler(
new StubServiceRouter());
handler.SetNext(routing);
var request = TestRequestFactory.CreateValid();
var response = await handler.HandleAsync(request);
Assert.Equal(200, response.StatusCode);
}
}
Full chain integration tests verify the handlers work together:
namespace Gateway.Pipeline.Tests;
public sealed class FullPipelineTests
{
private RequestHandler BuildChain(
bool tokenValid = true,
bool authorized = true)
{
var rateLimit = new RateLimitHandler(
maxRequests: 100);
var auth = new AuthenticationHandler(
new StubTokenValidator(tokenValid));
var authz = new AuthorizationHandler(
new StubAuthorizationService(authorized));
var validation = new InputValidationHandler(
new StubRequestBodyValidator(true));
var routing = new RequestRoutingHandler(
new StubServiceRouter());
rateLimit.SetNext(auth);
auth.SetNext(authz);
authz.SetNext(validation);
validation.SetNext(routing);
return rateLimit;
}
[Fact]
public async Task
HandleAsync_ValidRequest_Returns200()
{
var pipeline = BuildChain();
var request = TestRequestFactory.CreateValid();
var response = await pipeline
.HandleAsync(request);
Assert.Equal(200, response.StatusCode);
Assert.Equal("OK", response.Message);
}
[Fact]
public async Task
HandleAsync_InvalidToken_ShortCircuitsAt401()
{
var pipeline = BuildChain(tokenValid: false);
var request = TestRequestFactory.CreateValid();
var response = await pipeline
.HandleAsync(request);
Assert.Equal(401, response.StatusCode);
}
[Fact]
public async Task
HandleAsync_Unauthorized_ShortCircuitsAt403()
{
var pipeline = BuildChain(authorized: false);
var request = TestRequestFactory.CreateValid();
var response = await pipeline
.HandleAsync(request);
Assert.Equal(403, response.StatusCode);
}
[Fact]
public async Task
HandleAsync_ExceedsRateLimit_Returns429()
{
var rateLimit = new RateLimitHandler(
maxRequests: 2);
var routing = new RequestRoutingHandler(
new StubServiceRouter());
rateLimit.SetNext(routing);
var request = TestRequestFactory.CreateValid();
await rateLimit.HandleAsync(request);
await rateLimit.HandleAsync(request);
var response = await rateLimit
.HandleAsync(request);
Assert.Equal(429, response.StatusCode);
}
}
The BuildChain helper accepts parameters that control which stubs succeed or fail, making it trivial to test every short-circuit point without duplicating setup code. Compare this to the monolithic gateway -- you'd need to mock every dependency simultaneously and verify behavior through a single sprawling method.
If you're interested in how the proxy design pattern or the decorator design pattern handle similar concerns with different structural tradeoffs, those patterns are worth comparing.
Frequently Asked Questions
How is the chain of responsibility pattern different from middleware in ASP.NET Core?
ASP.NET Core middleware and the chain of responsibility pattern share the same fundamental idea: a request flows through a sequence of processing steps, and any step can short-circuit the pipeline. The key difference is structural. Middleware uses a delegate-based pipeline managed by the framework, while the chain of responsibility pattern uses explicit object references with a SetNext method. The chain of responsibility pattern gives you stronger type safety and is portable to any context -- not just HTTP request processing.
When should I use the chain of responsibility pattern instead of a simple switch statement?
Use the chain of responsibility pattern when the processing stages are independently complex, need to be reordered or reconfigured, or when new stages are added frequently. A switch statement works fine when you're dispatching to a fixed, well-known set of handlers that rarely change. Once you find yourself adding new cases every sprint and the method is growing past a screen's length, the chain gives you a much cleaner extension path.
Can handlers in the chain modify the request before passing it along?
Yes. Handlers can enrich or transform the request before delegating to the next handler. For example, an authentication handler could attach a parsed user identity to the request object after validating the token. Use a mutable context object that handlers can write to, but keep modifications documented so future developers know which handler produces which data.
How do I handle errors that occur deep in the handler chain?
Use the ObservableHandler base class shown in this article, which catches exceptions at each handler level and logs exactly which handler failed. For production systems, combining per-handler exception handling with a global error boundary gives you both specificity and safety.
What's the performance impact of a long handler chain?
Each handler adds one async method call and one null check. This overhead is negligible compared to the actual work each handler performs -- database lookups, HTTP calls, token validation. Profile before optimizing, and focus on the I/O-bound operations inside your handlers rather than the chain structure itself.
Should each handler be a singleton or transient in the DI container?
It depends on whether the handler holds state. The RateLimitHandler tracks per-client request counts, so it needs to be a singleton. Stateless handlers like InputValidationHandler can be transient. When in doubt, register as singleton and ensure your handler is thread-safe.
How do I add a new handler without modifying existing code?
Register the new handler in the DI container, then add it to the array in PipelineFactory.CreatePipeline() at the position where you want it to execute. No existing handler code changes. This is the Open/Closed Principle in action -- the chain is open for extension (new handlers) but closed for modification (existing handlers remain untouched).
Wrapping Up This Chain of Responsibility Pattern Example
This implementation demonstrates the chain of responsibility pattern handling a real infrastructure concern -- API gateway request processing -- with five handlers that each focus on a single responsibility. We started with a monolithic method that tangled every concern together and ended with independent, testable handlers that snap together in a configurable pipeline.
The pattern scales gracefully. Adding a new concern means writing one handler class and inserting it into the factory's array. Removing one means deleting a single line. Take this implementation, swap the stubs for real JWT validators and authorization services, and you have a production-ready gateway pipeline.

