-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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-requestHttpClient) - 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.
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
The shape gives you three properties for free:
-
Symmetry. Pre- and post-processing live in the same file. A timer that starts before
nextand stops after is the canonical example. -
Short-circuiting. A middleware that wants to skip the rest simply returns without calling
next. Validation, caching, and authorization all use this. -
Composability. Every middleware reads and writes the same
RequestContext. Adding a step is one line at the registration site.
| 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.
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 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.
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.
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.
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
FileSystemWatcheror 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.
- 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.
-
Tutorial — build a small CLI from
dotnet new consoleto 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.
Documents Plumber v4.x · Repository · MIT License · Report an issue
Getting Started
Pipeline (core)
Testing
Serilog Extensions
Diagnostics
Recipes
- AWS Lambda — API Gateway
- AWS Lambda — SQS
- Azure Functions — HTTP
- SQS polling console
- ASP.NET Core integration
- BackgroundService worker
- Webhook receiver
- Multi-command CLI
- File watcher
- Configuration reload
Repo · NuGet · NuGet — Testing · NuGet — Serilog · NuGet — Diagnostics