Skip to content

Concepts

Mark Lauter edited this page May 10, 2026 · 3 revisions

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.

The problem Plumber solves

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.

The pipeline shape

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
Loading

That shape gives you three useful properties for free:

  1. Symmetry: pre-processing and post-processing live in the same file. A timer that starts before next and stops after is one obvious example; logging "started" and "completed" lines is another.
  2. 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.
  3. 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.

Vocabulary

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.

How Plumber compares to things you might already know

If any of these comparisons clicks, the rest of the wiki will read faster.

ASP.NET Core middleware

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 other command-bus libraries

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.

Decorator / 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 a stack of decorators by hand — new LoggingDecorator(new CachingDecorator(new ValidatingDecorator(handler))) — Plumber is what you build to stop writing that line.

Plain method chaining

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-request HttpClient)
  • 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.

When to reach for Plumber

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 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 of these.

When something else might fit better

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.

Where to next

  • Tutorial — build a small CLI from dotnet new console to 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.

Clone this wiki locally