Skip to content

Concepts

Mark Lauter edited this page Jun 13, 2026 · 3 revisions

Concepts

A Plumber pipeline is a chain of small components — middleware — that runs against a typed request and produces a typed response. Each middleware decides whether to call the next one or short-circuit with a response.

The shape comes from ASP.NET Core middleware, applied outside an HTTP host: console apps, AWS Lambda, queue consumers, file processors.

The rest of this page covers the model and vocabulary. The Tutorial is where you write code.

The problem

Many programs start with a straight-line method:

public TextReport Process(string input)
{
    if (string.IsNullOrWhiteSpace(input))
        return TextReport.Empty(input, "input must be non-empty");

    var stopwatch = Stopwatch.StartNew();
    var normalized = input.ToLowerInvariant();
    var tokens = _tokenizer.Tokenize(normalized);
    stopwatch.Stop();

    return new TextReport(input, normalized, tokens, tokens.Length, stopwatch.Elapsed);
}

This is fine while the work is short and stable.

It stops being fine once you need:

  • A logger and request ID consistent across every step
  • Configuration loaded once and shared
  • A DI scope per call (per-request DbContext, per-request HttpClient)
  • Cancellation that flows through every step uniformly
  • New steps (caching, retries, authorization) added without touching the existing ones
  • Tests that exercise the real pipeline with selected services swapped

A pipeline library lets you express each step as a small focused unit and composes them at startup. Shared infrastructure is wired up once for the chain; each step receives it the same way.

You can build the same shape with decorators, chain of responsibility, or a mediator pattern. A pipeline library is what you reach for after writing that scaffolding for the third or fourth project.

The pipeline shape

Execution is an onion.

Code before await next(context) runs going in, in registration order. Code after runs coming back, in reverse. A request travels inward; the response travels outward.

sequenceDiagram
    participant Caller
    participant MW1 as Middleware 1
    participant MW2 as Middleware 2
    participant MW3 as Middleware 3

    Caller->>+MW1: request
    Note over MW1: pre-processing
    MW1->>+MW2: next(context)
    Note over MW2: pre-processing
    MW2->>+MW3: next(context)
    Note over MW3: pre-processing
    MW3-->>-MW2: return
    Note over MW2: post-processing
    MW2-->>-MW1: return
    Note over MW1: post-processing
    MW1-->>-Caller: response
Loading

The shape gives you three properties for free:

  1. Symmetry. Pre- and post-processing live in the same file. A timer that starts before next and stops after is the canonical example.
  2. Short-circuiting. A middleware that wants to skip the rest simply returns without calling next. Validation, caching, and authorization all use this.
  3. Composability. Every middleware reads and writes the same RequestContext. Adding a step is one line at the registration site.

Vocabulary

Term Meaning
Request The input value, typed TRequest. Anything: a string, a record, a Lambda event, a queue message.
Response The output value, typed TResponse. Use Unit for pipelines that have no meaningful return value.
Handler The compiled pipeline. Built once with RequestHandlerBuilder.Create<TReq, TRes>().Build(), then invoked many times with handler.InvokeAsync(request).
Middleware A unit of work in the pipeline. Either a delegate (one-off transformations) or a class (anything with dependencies).
Context A RequestContext<TReq, TRes> passed through every middleware. Carries the request, the response slot, the per-request DI scope, the cancellation token, an Id, an Elapsed clock, and a Data dictionary.
Scope Each call to InvokeAsync creates a new DI scope. Method-injected services on InvokeAsync are resolved from that scope on every request — the safe place for DbContext, HttpClient, and any other per-request lifetime.
Builder A RequestHandlerBuilder<TReq, TRes> that collects configuration sources, service registrations, and logging callbacks, then produces a handler when you call Build().

The Reference pages cover the full API surface — see Building a pipeline, Middleware, and Request lifecycle.

Comparisons

ASP.NET Core middleware

If you've written app.Use(...) in a Program.cs, you've used this exact shape.

Plumber is the same pattern — onion model, next delegate, per-request DI scope — applied to a typed (TRequest, TResponse) pair instead of HttpContext. The mental model transfers wholesale; the typed shape is the only real difference.

(Steve Gordon's walkthrough of the ASP.NET Core middleware pipeline is the original inspiration. New to middleware? Microsoft's primer introduces the concept.)

MediatR and command-bus libraries

MediatR and Plumber overlap. Both let you write small handlers that the framework dispatches to. Both have a wrapping concept — "behaviors" in MediatR, "middleware" in Plumber.

Differences:

  • Shape. MediatR is many handlers (one per request type) routed by type. Plumber is one chain (per (TRequest, TResponse) pair) traversed in registration order.
  • Configuration. MediatR drops into your existing host's DI. Plumber owns its own DI when you want it to, and reuses an existing host's DI when you have one (see ASP.NET Core integration).
  • Fit. MediatR fits when your application is organized around a command/query bus. Plumber fits when you have one request type and want to compose the steps that handle it.

Use MediatR when the dispatcher is the point. Use Plumber when the pipeline is the point.

Decorator and chain of responsibility (the GoF patterns)

Plumber is a concrete implementation of these patterns with the wiring done for you.

If you've ever written new LoggingDecorator(new CachingDecorator(new ValidatingDecorator(handler))) by hand, Plumber is what you build to stop writing that line.

Plain method chaining

The straight-line version above is fine when the pipeline is short and stable.

The cost of not using a pipeline library is invisible until you need DI scopes, uniform cancellation, consistent logging, or tests that exercise the real pipeline with selected services swapped. When you reach for any of those, a pipeline library starts paying for itself.

When to reach for Plumber

Good fits:

  • AWS Lambda functions (API Gateway, SQS/SNS, DynamoDB Streams, EventBridge)
  • Azure Functions in the isolated worker model
  • Long-running queue consumers (RabbitMQ, Kafka, Azure Service Bus, SQS polling)
  • File and batch processors triggered by FileSystemWatcher or a schedule
  • Console apps and CLI tools that need ordered, composable steps with DI and configuration
  • Webhook receivers and other request/response endpoints inside an existing host
  • Any pipeline you'd reach for ASP.NET Core middleware in, but without the web host

The Recipes section has a full walkthrough for each.

When something else fits better

  • You're already inside a full host and the work is HTTP request handling. Use ASP.NET Core middleware. Plumber is for the parts of your system that aren't HTTP request handling. (Plumber can still run inside an ASP.NET host for the non-HTTP parts — see ASP.NET Core integration.)
  • You need many request types routed by type. That's MediatR's shape. Plumber fits one request type at a time.
  • The work is genuinely one step. A single function call doesn't need a pipeline library. Reach for Plumber when you have at least three steps that share infrastructure.

Where to next

  • Tutorial — build a small CLI from dotnet new console to a tested pipeline. One new concept per section.
  • Sample.Cli walkthrough — guided tour of the sample app in the repo.
  • Building a Pipeline — the full builder surface if you'd rather skip the tutorial.

Clone this wiki locally