← Back to all posts

Chain of Responsibility Pattern in .NET

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:

  1. create a new handler
  2. insert it at the right point in the chain
  3. 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