Chain of Responsibility Pattern in .NET
Reading Time: 6 minutes
The Problem
Some workflows start small and then slowly turn into long conditional blocks.
A common example is document upload processing:
- reject empty files
- reject unsupported extensions
- scan the filename for suspicious patterns
- verify the tenant still has storage quota
- optionally route large files for manual review
At first, it is tempting to place everything in one service:
public sealed class DocumentUploadProcessor
{
public async Task<UploadResult> ProcessAsync(DocumentUploadRequest request, CancellationToken ct)
{
if (request.File is null || request.File.Length == 0)
{
return UploadResult.Fail("The uploaded file is empty.");
}
var extension = Path.GetExtension(request.File.FileName);
if (extension is not ".pdf" and not ".docx")
{
return UploadResult.Fail("Only PDF and DOCX files are allowed.");
}
if (request.File.FileName.Contains("..", StringComparison.Ordinal))
{
return UploadResult.Fail("The file name is invalid.");
}
if (!await HasAvailableQuotaAsync(request.TenantId, request.File.Length, ct))
{
return UploadResult.Fail("Storage quota has been exceeded.");
}
if (request.File.Length > 25 * 1024 * 1024)
{
return UploadResult.RequiresManualReview("Large files must be reviewed before processing.");
}
return UploadResult.Success();
}
}
This works for a while, but it creates predictable problems:
- the class becomes harder to read every time a rule is added
- each rule is tightly coupled to the others
- reordering or reusing rules becomes awkward
- tests drift toward large “kitchen sink” scenarios
That is usually a signal that the workflow wants a pipeline instead of one growing method.
The Solution
The Chain of Responsibility Pattern lets you model each step as a separate handler. Every handler decides one of three things:
- stop the pipeline with a failure
- stop the pipeline with an alternate outcome
- pass control to the next handler
In .NET, this is a good fit for validation, approval flows, policy checks, and command preprocessing.
Instead of one large block of conditionals, you get a sequence of small classes with a single reason to change.
Description
This example targets .NET 10 and uses only the built-in ASP.NET Core shared framework. No third-party NuGet package is required for the pattern itself.
1. Define the request and result models
public sealed record DocumentUploadRequest(
string TenantId,
IFormFile File,
string UploadedBy);
public sealed record UploadResult(
bool Succeeded,
bool RequiresReview,
string? Error)
{
public static UploadResult Success() => new(true, false, null);
public static UploadResult Fail(string error) => new(false, false, error);
public static UploadResult RequiresManualReview(string message) => new(false, true, message);
}
2. Create a base handler abstraction
Each handler gets a chance to evaluate the request. If it has nothing to reject or reroute, it forwards the call.
public interface IUploadHandler
{
Task<UploadResult> HandleAsync(DocumentUploadRequest request, CancellationToken ct = default);
IUploadHandler SetNext(IUploadHandler next);
}
public abstract class UploadHandler : IUploadHandler
{
private IUploadHandler? _next;
public IUploadHandler SetNext(IUploadHandler next)
{
_next = next;
return next;
}
public virtual async Task<UploadResult> HandleAsync(DocumentUploadRequest request, CancellationToken ct = default)
{
if (_next is null)
{
return UploadResult.Success();
}
return await _next.HandleAsync(request, ct);
}
}
3. Implement focused handlers
public sealed class EmptyFileHandler : UploadHandler
{
public override Task<UploadResult> HandleAsync(DocumentUploadRequest request, CancellationToken ct = default)
{
if (request.File.Length == 0)
{
return Task.FromResult(UploadResult.Fail("The uploaded file is empty."));
}
return base.HandleAsync(request, ct);
}
}
public sealed class FileExtensionHandler : UploadHandler
{
private static readonly HashSet<string> AllowedExtensions =
[".pdf", ".docx"];
public override Task<UploadResult> HandleAsync(DocumentUploadRequest request, CancellationToken ct = default)
{
var extension = Path.GetExtension(request.File.FileName);
if (!AllowedExtensions.Contains(extension))
{
return Task.FromResult(UploadResult.Fail("Only PDF and DOCX files are allowed."));
}
return base.HandleAsync(request, ct);
}
}
public sealed class FileNameSafetyHandler : UploadHandler
{
public override Task<UploadResult> HandleAsync(DocumentUploadRequest request, CancellationToken ct = default)
{
if (request.File.FileName.Contains("..", StringComparison.Ordinal))
{
return Task.FromResult(UploadResult.Fail("The file name is invalid."));
}
return base.HandleAsync(request, ct);
}
}
Now the handlers are small, testable, and independent.
4. Add a handler that depends on infrastructure
Rules that need external state fit just as well.
public interface IStorageQuotaService
{
Task<bool> HasAvailableQuotaAsync(string tenantId, long newFileSize, CancellationToken ct = default);
}
public sealed class StorageQuotaHandler : UploadHandler
{
private readonly IStorageQuotaService _storageQuotaService;
public StorageQuotaHandler(IStorageQuotaService storageQuotaService)
{
_storageQuotaService = storageQuotaService;
}
public override async Task<UploadResult> HandleAsync(DocumentUploadRequest request, CancellationToken ct = default)
{
var hasQuota = await _storageQuotaService.HasAvailableQuotaAsync(
request.TenantId,
request.File.Length,
ct);
if (!hasQuota)
{
return UploadResult.Fail("Storage quota has been exceeded.");
}
return await base.HandleAsync(request, ct);
}
}
public sealed class LargeFileReviewHandler : UploadHandler
{
private const long ManualReviewThresholdBytes = 25 * 1024 * 1024;
public override Task<UploadResult> HandleAsync(DocumentUploadRequest request, CancellationToken ct = default)
{
if (request.File.Length > ManualReviewThresholdBytes)
{
return Task.FromResult(
UploadResult.RequiresManualReview("Large files must be reviewed before processing."));
}
return base.HandleAsync(request, ct);
}
}
5. Compose the chain
You can wire the chain manually in a factory or composition root.
public interface IUploadValidationPipeline
{
Task<UploadResult> ExecuteAsync(DocumentUploadRequest request, CancellationToken ct = default);
}
public sealed class UploadValidationPipeline : IUploadValidationPipeline
{
private readonly IUploadHandler _root;
public UploadValidationPipeline(IStorageQuotaService storageQuotaService)
{
var emptyFile = new EmptyFileHandler();
var fileExtension = new FileExtensionHandler();
var fileNameSafety = new FileNameSafetyHandler();
var storageQuota = new StorageQuotaHandler(storageQuotaService);
var largeFileReview = new LargeFileReviewHandler();
emptyFile
.SetNext(fileExtension)
.SetNext(fileNameSafety)
.SetNext(storageQuota)
.SetNext(largeFileReview);
_root = emptyFile;
}
public Task<UploadResult> ExecuteAsync(DocumentUploadRequest request, CancellationToken ct = default)
=> _root.HandleAsync(request, ct);
}
6. Use it from an endpoint
app.MapPost("/documents", async (
[FromForm] IFormFile file,
[FromForm] string tenantId,
ClaimsPrincipal user,
IUploadValidationPipeline pipeline,
CancellationToken ct) =>
{
var request = new DocumentUploadRequest(
tenantId,
file,
user.Identity?.Name ?? "system");
var result = await pipeline.ExecuteAsync(request, ct);
if (result.RequiresReview)
{
return Results.Accepted(value: new { message = result.Error });
}
if (!result.Succeeded)
{
return Results.BadRequest(new { error = result.Error });
}
return Results.Ok(new { message = "Upload accepted." });
});
Why This Pattern Works Well
The main benefit is not just cleaner code. It is better change isolation.
When a new rule arrives, you can usually:
- create a new handler
- insert it at the right point in the chain
- add a focused test for only that rule
That is much safer than modifying one long method full of branching logic.
When To Use It
Chain of Responsibility is a strong fit when:
- a request moves through a clear sequence of checks
- each step can accept, reject, or forward the request
- rules change independently over time
- you want to compose or reorder behavior without rewriting the whole flow
Typical .NET examples include:
- validation pipelines
- approval workflows
- discount or pricing rules
- message preprocessing before publishing
- security policy evaluation
Common Mistakes
- making handlers depend on too much shared mutable state
- using the pattern for tiny flows that only have two simple checks
- hiding handler order so deeply that the pipeline becomes hard to understand
- putting unrelated business workflows into a single chain
Keep handlers focused and keep the pipeline composition explicit.
Summary
The Chain of Responsibility Pattern helps you replace growing conditional blocks with a sequence of small, composable handlers.
In .NET, that leads to:
- better separation of concerns
- easier testing
- simpler rule changes
- more maintainable request pipelines
If a class keeps accumulating one more validation, one more rule, and one more special case, that is usually the right moment to consider a chain.
References
- Chain of Responsibility pattern: https://refactoring.guru/design-patterns/chain-of-responsibility
- Chain of Responsibility in C#: https://refactoring.guru/design-patterns/chain-of-responsibility/csharp/example
- Design patterns overview for .NET developers: https://learn.microsoft.com/dotnet/standard/design-guidelines/