BrandGhost
Pipeline vs Chain of Responsibility vs ASP.NET Core Middleware

Pipeline vs Chain of Responsibility vs ASP.NET Core Middleware

The difference between pipeline vs Chain of Responsibility vs middleware is not that one has stages, another has handlers, and another runs HTTP code. All three can look like an ordered list of components. The meaningful difference is who owns continuation and what each component is expected to do.

A Pipeline usually models staged transformation. Each stage participates in producing the next representation or state. Chain of Responsibility models potential handler selection. Each handler may handle a request or forward it to another candidate. ASP.NET Core middleware models continuation-based interception around an HTTP request in which each middleware receives next, decides whether to call it, and can run code both before and after it.

Those distinctions affect ordering, short-circuiting, testing, and the shape of the result. Once you identify the owner of continuation, the three models stop looking interchangeable.

The comparison examples target net10.0 with C# 14 and nullable reference types enabled, and the local build validation artifact records the exact SDK, framework, and warning-free build result. The middleware behavior is based on the stable ASP.NET Core 10 documentation.

Pipeline vs Chain of Responsibility vs Middleware at a Glance

Question Pipeline Chain of Responsibility ASP.NET Core middleware
Primary purpose Staged transformation or processing Select a capable handler Intercept and compose HTTP request/response behavior
Normal participation Every required stage runs in order One or more candidate handlers may decline Each reached middleware decides whether to call next
Continuation owner Usually an external runner Usually the current handler The current middleware
Input/output shape Often changes across stages Usually one request remains the same request Shared HttpContext, asynchronous Task return
Short-circuit meaning Stop because the pipeline reached a terminal outcome Stop because a handler accepted the request Stop because middleware did not invoke next
Reverse unwind Not inherent Not inherent Built into code after await next(context)
Ordering significance Transformation dependency Candidate precedence Request order plus reverse response order

This table describes the center of gravity for each model. Real systems can blend characteristics. A pipeline can reject an item. A Chain of Responsibility handler can enrich a request before forwarding it. Middleware can transform values stored in HttpContext. The question is which behavior defines the architecture.

Pipeline Means Staged Transformation

Microsoft's Pipes and Filters pattern guidance describes processing as independent filters connected by pipes, with each filter consuming input and producing output. In an in-process C# Pipeline, those boundaries may simply be method calls and typed values.

The important invariant is that the stages are part of the planned transformation. Validation is not merely a candidate that might handle an order. Pricing is not another candidate for the same request. The output of validation becomes the input to pricing.

namespace PatternComparison.PipelineExample;

public sealed record RawOrder(
    string CustomerId,
    IReadOnlyList<RawOrderLine> Lines);

public sealed record RawOrderLine(
    string Sku,
    int Quantity,
    decimal UnitPrice);

public sealed record ValidatedOrder(
    string CustomerId,
    IReadOnlyList<RawOrderLine> Lines);

public sealed record PricedOrder(
    string CustomerId,
    decimal Total);

public static class OrderPipeline
{
    public static PricedOrder Execute(RawOrder input)
    {
        ValidatedOrder validated = Validate(input);
        PricedOrder priced = CalculatePrice(validated);

        return priced;
    }

    private static ValidatedOrder Validate(RawOrder input)
    {
        if (string.IsNullOrWhiteSpace(input.CustomerId))
        {
            throw new ArgumentException(
                "A customer identifier is required.",
                nameof(input));
        }

        if (input.Lines.Count == 0)
        {
            throw new ArgumentException(
                "At least one order line is required.",
                nameof(input));
        }

        return new ValidatedOrder(
            input.CustomerId.Trim(),
            input.Lines);
    }

    private static PricedOrder CalculatePrice(
        ValidatedOrder input)
    {
        decimal total = input.Lines.Sum(
            line => line.Quantity * line.UnitPrice);

        return new PricedOrder(input.CustomerId, total);
    }
}

The Execute method owns continuation. Individual transformations do not receive a next delegate and do not search for a capable successor. The sequence is explicit: RawOrder becomes ValidatedOrder, then PricedOrder.

A reusable runner can own the same sequencing when stages share one contract. The architectural point remains unchanged. The runner decides which stage comes next.

Chain of Responsibility Means Potential Handler Selection

The original GoF-derived Chain of Responsibility excerpt defines the intent as giving more than one object a chance to handle a request, then passing it along until an object handles it. The sender does not need to know the final receiver.

That is a selection model. A support request can be handled by billing, technical support, or a fallback. Each handler is a candidate for substantially the same request:

namespace PatternComparison.ChainExample;

public sealed record SupportRequest(
    string Category,
    string Description);

public interface ISupportHandler
{
    ISupportHandler SetNext(ISupportHandler next);

    string Handle(SupportRequest request);
}

public abstract class SupportHandler : ISupportHandler
{
    private ISupportHandler? _next;

    public ISupportHandler SetNext(ISupportHandler next)
    {
        _next = next;
        return next;
    }

    public virtual string Handle(SupportRequest request)
    {
        return _next?.Handle(request)
            ?? "No handler accepted the request.";
    }
}

public sealed class BillingHandler : SupportHandler
{
    public override string Handle(SupportRequest request)
    {
        return request.Category.Equals(
            "billing",
            StringComparison.OrdinalIgnoreCase)
            ? $"Billing accepted: {request.Description}"
            : base.Handle(request);
    }
}

public sealed class TechnicalHandler : SupportHandler
{
    public override string Handle(SupportRequest request)
    {
        return request.Category.Equals(
            "technical",
            StringComparison.OrdinalIgnoreCase)
            ? $"Technical support accepted: {request.Description}"
            : base.Handle(request);
    }
}

public static class SupportChain
{
    public static ISupportHandler Create()
    {
        BillingHandler billing = new();
        TechnicalHandler technical = new();

        billing.SetNext(technical);

        return billing;
    }
}

The request does not become a different typed representation at each hop. Billing inspects it and either handles it or delegates. Technical support receives the same conceptual request only if billing declines.

For a deeper implementation treatment, see my Chain of Responsibility design pattern guide. The boundary that matters for this comparison is candidate selection, not the particular base-class syntax.

ASP.NET Core Middleware Owns Continuation

ASP.NET Core middleware has a stronger continuation contract than a typical staged Pipeline. The current ASP.NET Core 10 middleware documentation states that each middleware chooses whether to pass the request to the next component and can perform work before and after that call.

The framework's RequestDelegate API receives an HttpContext and returns a Task. A component registered with Use receives the next delegate. Run registers a terminal delegate, so that branch does not continue to later middleware.

using Microsoft.AspNetCore.Http;

WebApplicationBuilder builder =
    WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

app.Use(async (HttpContext context, RequestDelegate next) =>
{
    Console.WriteLine("A: request");

    await next(context);

    Console.WriteLine("A: response");
});

app.Use(async (HttpContext context, RequestDelegate next) =>
{
    Console.WriteLine("B: request");

    if (context.Request.Path == "/blocked")
    {
        context.Response.StatusCode =
            StatusCodes.Status403Forbidden;
        await context.Response.WriteAsync("Blocked.");
        return;
    }

    await next(context);

    Console.WriteLine("B: response");
});

app.Run(async context =>
{
    Console.WriteLine("Endpoint");
    await context.Response.WriteAsync("Hello.");
});

await app.RunAsync();

For a normal request, the ASP.NET Core middleware ordering contract produces request-side execution in registration order and response-side execution in reverse order:

A: request
B: request
Endpoint
B: response
A: response

For /blocked, middleware B does not call next, which short-circuits the remaining request pipeline. The endpoint is not reached, B's code after next is not present on that branch, and control returns to middleware A. A can still run its response-side code because A already called B and is now unwinding.

That nested call shape is the key. Middleware does not merely iterate forward. Each component wraps the downstream delegate it invokes.

My ASP.NET Core middleware guide goes further into framework usage. For this article, the technical authority is the current ASP.NET Core 10 documentation, and the selection rule is simple: use middleware when HTTP request/response interception and continuation are the primary model.

See Why Middleware Unwinds in Reverse

Suppose middleware is registered as A, B, and C. Under the documented request and response ordering, the request enters A, then B, then C, and if each component awaits next, completion returns from C to B to A.

That creates two orderings:

  1. Request-side work executes A, B, C.
  2. Response-side work executes C, B, A.

A regular staged Pipeline has no automatic reverse pass. If three transformations run A, B, C, the result simply leaves C unless the runner explicitly adds cleanup, compensation, or another reverse operation.

Chain of Responsibility also has no inherent response unwind. A handler can call its successor and then perform work afterward, but that makes the implementation more middleware-like or Decorator-like. It is not required by the GoF handler-selection intent.

This wrapping quality is why middleware can resemble Decorator as well as Chain of Responsibility. My Chain of Responsibility vs Decorator comparison explores that distinction. Middleware often combines candidate continuation with nested wrapping, while a classic Pipeline usually emphasizes forward transformation.

Short-Circuiting Does Not Mean the Same Thing Everywhere

The phrase "short-circuit" appears in all three designs, but its meaning follows the model.

Pipeline short-circuit

A pipeline runner stops because a stage produced a terminal result, such as rejection. The runner still owns the rule. The stage returns an outcome; it does not normally invoke or withhold a continuation delegate.

Chain of Responsibility short-circuit

A handler accepts the request and does not forward it. That is the expected selection behavior. The chain found a receiver.

Middleware short-circuit

A middleware component that does not call next short-circuits later components. It becomes terminal for that request branch, usually by writing a response or otherwise completing request handling.

These cases can produce similar control flow, but they express different contracts. Calling all three "pipelines with early exit" loses the reason the components exist.

Ordering Answers Different Questions

In a Pipeline, order answers: "Which transformation must happen before the next transformation can consume its input?"

In Chain of Responsibility, order answers: "Which candidate gets the first opportunity to handle the request?"

In middleware, order answers both: "Which component sees the request first?" and "Which wrapper sees the response last?"

That is why registration order has different consequences. Moving validation after pricing in a Pipeline can violate a data dependency. Moving a general handler before a specific handler in Chain of Responsibility can prevent the specific handler from ever receiving matching requests. Moving authorization or response compression middleware changes which HTTP behavior wraps which downstream components.

Order is not merely a list position. It expresses dependency, precedence, or nesting.

Compare the Same Requirement Across All Three Models

Consider a requirement that says, "Reject an invalid order, record diagnostics, and return an HTTP response." That sentence can involve all three models without making them equivalent.

The Pipeline might validate and price the order. Validation returns a terminal rejection, so the runner does not execute pricing. The Pipeline's outcome describes business processing, not an HTTP response.

A Chain of Responsibility might select the component capable of handling the order type. A retail handler declines wholesale orders, while a wholesale handler accepts them. Selection determines the receiver; it does not replace the order-validation stages inside that receiver.

ASP.NET Core middleware might attach a correlation identifier before the endpoint and translate an unhandled downstream exception into an HTTP response. It wraps the HTTP execution. It should not become the hidden home for pricing rules merely because every request passes through it.

The resulting call path can be:

  1. Middleware establishes HTTP-specific context and calls next.
  2. Endpoint code asks a handler chain to select the responsible use case.
  3. The selected handler executes a typed business Pipeline.
  4. Control returns through the middleware response path.

Each layer has a separate reason to exist. Removing the names and looking only at "a list of components" would erase those responsibilities.

Recognize When a Hybrid Has Changed Its Center of Gravity

A design can start as one model and gradually adopt another model's defining behavior. That is not automatically wrong, but the contract should be renamed or documented before callers form the wrong expectations.

If every Pipeline stage receives next and may wrap downstream work, the design is middleware-like. If stages mostly inspect the same request and the first capable component handles it, the design is Chain-of-Responsibility-like. If handlers always run and each produces the next typed representation, the design is Pipeline-like even if the classes are named handlers.

Use the behavioral contract to classify the design. Class names such as Processor, Handler, Filter, and Middleware are hints, not proof.

This classification also gives reviewers a shared vocabulary for challenging order, ownership, and short-circuit assumptions.

Keep the HTTP Lifetime Boundary Visible

ASP.NET Core middleware is tied to HttpContext, the HTTP request lifetime, and framework startup composition. The custom middleware documentation for ASP.NET Core 10 explains that convention-based middleware is constructed once for the application lifetime. Scoped dependencies belong in InvokeAsync, while factory-activated IMiddleware supports per-request activation.

That detail does not turn this into a dependency injection tutorial. It reinforces the boundary: middleware is not a transport-neutral stage abstraction. Its lifecycle and contract are designed for an ASP.NET Core request.

If the same business transformation must run from an HTTP endpoint, a queue consumer, and a command-line tool, put the transformation in an ordinary service or Pipeline stage. Let middleware handle HTTP-specific concerns and call that service at the appropriate boundary.

Use Microsoft Agent Framework Middleware Only as an Analogy

Current Microsoft Agent Framework middleware documentation describes agent-run, function-calling, and IChatClient middleware. Multiple middleware instances form a continuation chain and receive a supplied next function, as the current middleware continuation example shows.

That makes MAF middleware a useful analogy for continuation-based interception outside HTTP. It is not technical authority for the generic Pipeline Pattern, Chain of Responsibility, or ASP.NET Core middleware.

My existing Microsoft Agent Framework middleware article can provide ecosystem context. Its older API framing should not be copied into new implementation guidance. Use the current Microsoft documentation for current APIs and versions.

Choose by Continuation Ownership

When the names become blurry, ask these questions in order:

  1. Must each component transform data for the next required component? That points to a Pipeline.
  2. Are several components candidates, with the actual receiver unknown to the sender? That points to Chain of Responsibility.
  3. Does each component receive next, wrap downstream work, and operate on HttpContext? That is ASP.NET Core middleware.
  4. Is reverse response behavior essential? Middleware has that shape directly.
  5. Must the logic run outside HTTP? Keep the business processing outside middleware, even if middleware initiates it.

There is no need to force one label onto a system that deliberately combines models. An HTTP endpoint can run through middleware, invoke a Chain of Responsibility to select a handler, and then execute a typed Pipeline inside the selected handler. The architecture remains understandable when each layer keeps its own contract.

Frequently Asked Questions

Is ASP.NET Core middleware a Pipeline?

It is called a request pipeline, but its defining programming model is continuation-based HTTP interception. Each middleware owns whether next is called and can wrap downstream work, as documented by the ASP.NET Core 10 middleware contract.

Is middleware also Chain of Responsibility?

It has Chain-of-Responsibility-like delegation because a component may continue or stop. It also has Decorator-like reverse unwind because the documented middleware response path runs code after await next while the response returns.

Can a Pipeline short-circuit?

Yes. A runner can stop after a stage returns a terminal rejection or failure. The difference is that the runner usually owns continuation rather than passing a next delegate into every stage.

Does Chain of Responsibility require exactly one handler?

Its classic intent is to pass a request until an eligible handler accepts it. Variations may allow observation or forwarding after handling, but that should be explicit because it changes the simple selection contract.

Why does ASP.NET Core middleware order matter twice?

The ASP.NET Core middleware ordering guidance states that registration order controls the inbound request path and code after next runs while calls unwind in reverse response order.

Should business stages receive HttpContext?

Only if the behavior is genuinely HTTP-specific. Transport-neutral transformations are easier to reuse and test when they receive explicit business inputs rather than the entire HTTP context.

Name the Model by Its Responsibility

The practical distinction in pipeline vs Chain of Responsibility vs middleware is responsibility, not surface syntax.

Use a Pipeline for required staged transformation. Use Chain of Responsibility when a sender should not know which candidate will handle a request. Use ASP.NET Core middleware when components intercept an HTTP request, own continuation, can short-circuit, and may wrap the response path in reverse order.

Once that boundary is explicit, stage order, handler precedence, and middleware registration stop being interchangeable implementation details. They become understandable parts of the architecture.

Chain of Responsibility Pattern Best Practices in C#: Code Organization and Maintainability

Explore chain of responsibility pattern best practices in C# including handler design, chain construction, error handling, and testable pipeline architectures.

Chain of Responsibility Pattern Real-World Example in C#: Complete Implementation

Build a real-world chain of responsibility pattern example in C# with a complete HTTP request processing pipeline showing practical handler chain design.

Chain of Responsibility Design Pattern in C#: Complete Guide with Examples

Master the chain of responsibility design pattern in C# with practical examples showing handler chains, middleware pipelines, and real-world .NET implementations.

An error has occurred. This application may no longer respond until reloaded. Reload