-
Notifications
You must be signed in to change notification settings - Fork 0
Concepts
This page builds the mental model. By the end you should be able to read the rest of the wiki without bouncing back to look up vocabulary, and you should know whether Plumber is the right tool for what you're building. There's no code to run yet — that's what the Tutorial is for.
Imagine a small program that takes a string of text and produces a structured report — a normalized version, a list of tokens, a word count, a timing measurement. The straight-line version looks fine:
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);
}Now imagine the program grows. You want to add structured logging, then a cache, then authorization, then retries. Each new step needs the same surrounding scaffold: read configuration, get a logger, share state with the next step, handle cancellation, measure latency.
You can keep extending the method, but soon it's doing five things and the testing story gets painful. You can extract helpers, but the wiring between them stays manual. You can reach for design patterns — decorator, chain of responsibility, mediator — and you'll end up rebuilding what middleware libraries already provide.
A pipeline library lets you express each step as a small, focused unit, then composes them at startup. The shared infrastructure — DI scope, logger, cancellation token, request ID — gets wired up once for the whole chain, and each step receives it the same way.
A Plumber pipeline is a chain of middleware that runs against a request context. Each middleware decides whether to keep the chain going or short-circuit with a response.
The execution order 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
That shape gives you three useful properties for free:
-
Symmetry: pre-processing and post-processing live in the same file. A timer that starts before
nextand stops after is one obvious example; logging "started" and "completed" lines is another. -
Short-circuiting: a middleware that wants to skip the rest of the pipeline simply returns without calling
next. Validation, caching, and authorization all use this pattern. -
Composability: each middleware reads and writes the same
RequestContext, so adding a new step is a single line at the call site — no surgery in the middleware that already work.
| Term | Meaning |
|---|---|
| Request | The input value, typed TRequest. Can be 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 (for one-off transformations) or a class (for 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 for inter-middleware sharing. |
| Scope | Each call to InvokeAsync creates a new DI scope. Method-injected services on InvokeAsync are resolved fresh from that scope on every request — the safe place for DbContext, HttpClient, and anything else with a 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 full type catalog lives in the API cheat sheet; the table above covers everything you need to read the rest of Getting Started.
If any of these comparisons clicks, the rest of the wiki will read faster.
If you've written app.Use(...) in an ASP.NET Core Program.cs, you've already 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.)
MediatR and Plumber overlap in some places and diverge in others. Both let you write small handlers that the framework dispatches to. Both have a notion of "behaviors" (MediatR) or "middleware" (Plumber) that wrap the call.
The 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. If you have ten request types, MediatR has ten handlers; Plumber has ten handlers, each with its own pipeline. - Configuration: MediatR is "drop into your existing host's DI." Plumber owns its own DI and configuration when you want it to, and can also reuse an existing host's DI when you have one (see ASP.NET Core integration recipe).
- 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 — a Lambda function, a queue consumer, a CLI command. 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 a stack of decorators by hand — new LoggingDecorator(new CachingDecorator(new ValidatingDecorator(handler))) — Plumber is what you build to stop writing that line.
The straight-line version of the example above is fine when the pipeline is short and stable. The cost of not using a pipeline library is mostly invisible until you need:
- A different DI scope per call (per-request
DbContext, per-requestHttpClient) - Cancellation support that flows through every step uniformly
- A consistent logger and request ID across log lines
- The ability to add a step (caching, retries, authentication) without touching every existing step
- Tests that exercise the real pipeline with selected services swapped out
When you find yourself reaching for any of those, you're at the point where a pipeline library starts paying for itself.
Plumber is a good fit for:
- AWS Lambda functions (API Gateway requests, SQS/SNS events, 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 of these.
A few cases where Plumber is the wrong tool:
- Your application already runs inside a full host (ASP.NET Core, generic host) and the work is HTTP request handling. You already have middleware — use it. Plumber is for the parts of your system that aren't HTTP request handling. The host-mode integration documented in ASP.NET Core integration is for exactly that case: a non-HTTP pipeline that wants to share the host's services.
- 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've got at least three steps that need to share infrastructure.
-
Tutorial — build a small CLI from
dotnet new consoleto a working pipeline with tests. One new concept per section. - Sample.Cli walkthrough — guided tour of the realistic sample app in the repo, including the pieces that exist to support testing.
- Building a Pipeline — the full builder surface if you're ready to skip the tutorial and go straight to reference.
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