Skip to content

Building A Pipeline

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

Building a Pipeline

A typical Plumber pipeline has two halves:

  1. Builder configuration — registers configuration sources, services, and logging on a RequestHandlerBuilder<TRequest, TResponse>.
  2. Pipeline configuration — adds middleware to the built RequestHandler<TRequest, TResponse>.

Both halves can live inline in Program.cs. Splitting them into two methods (commonly CreateBuilder and Configure) makes the pipeline trivial to test against PlumberApplicationFactory<TReq, TRes> — the factory hands you the builder before Build() runs so you can swap services, then re-applies your Configure step against the resulting handler. Sample.Cli ships with the same shape.

This page is a reference for the builder surface. The pipeline half — the Use overloads on the built handler — lives in Middleware.

Creating a builder

RequestHandlerBuilder is a static factory; instances of RequestHandlerBuilder<TRequest, TResponse> are constructed through it. The constructor is internal, so this is the only entry point.

Two overloads:

RequestHandlerBuilder<TReq, TRes> Create<TReq, TRes>()
    where TReq : notnull;

RequestHandlerBuilder<TReq, TRes> Create<TReq, TRes>(string[] args)
    where TReq : notnull;

The parameterless overload is fine for unit tests and tiny utilities.

The args overload takes the program's command-line arguments and stashes them on the builder. They aren't applied immediately — Plumber appends them to the per-build configuration via AddCommandLine last during Build(), so command-line values always win against any other source you register.

using Plumber;

var builder = RequestHandlerBuilder.Create<MyRequest, MyResponse>(args);

TRequest is constrained where TRequest : notnull, not where TRequest : class — value-type requests work without ceremony. TResponse has no constraint; use Unit when there's no meaningful response to produce. Unit is a zero-sized record struct used as the response type for fire-and-forget pipelines.

Configuration sources

v3 configuration is opt-in. The builder starts with no sources registered — only the base path is set, defaulting to the current working directory. Each source you want has to be added explicitly.

Command-line args (when supplied to Create) are the only thing applied automatically, and they're applied last so they override everything else.

The full set of source-registration methods on the builder:

// JSON files
RequestHandlerBuilder<TReq, TRes> AddJsonFile(string path, bool optional);

// In-memory key/value pairs
RequestHandlerBuilder<TReq, TRes> AddInMemoryCollection(
    IEnumerable<KeyValuePair<string, string?>>? initialData = null);

// Environment variables
RequestHandlerBuilder<TReq, TRes> AddEnvironmentVariables();
RequestHandlerBuilder<TReq, TRes> AddEnvironmentVariables(string prefix);

// Base path for file providers
RequestHandlerBuilder<TReq, TRes> SetBasePath(string path);

// User secrets — pick the overload that matches your assembly setup
RequestHandlerBuilder<TReq, TRes> AddUserSecrets(string secretsid, bool optional);
RequestHandlerBuilder<TReq, TRes> AddUserSecrets<T>() where T : class;
RequestHandlerBuilder<TReq, TRes> AddUserSecrets<T>(bool optional) where T : class;

// Escape hatch — call the IConfigurationBuilder extension methods directly
RequestHandlerBuilder<TReq, TRes> ConfigureConfiguration(
    Action<IConfigurationBuilder, string[]> configure);

A typical pick-and-mix call site:

var env = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production";

var builder = RequestHandlerBuilder
    .Create<MyRequest, MyResponse>(args)
    .AddJsonFile("appsettings.json", optional: true)
    .AddJsonFile($"appsettings.{env}.json", optional: true)
    .AddEnvironmentVariables("MYAPP_")
    .AddInMemoryCollection([
        new("Feature:Enabled", "true"),
    ]);

ConfigureConfiguration — the escape hatch

ConfigureConfiguration is the escape hatch when one of the existing IConfigurationBuilder extensions doesn't have a wrapper on the builder yet — Azure App Configuration, Key Vault, custom providers, the lot:

builder.ConfigureConfiguration((config, args) =>
{
    config.AddCustomProvider();
});

The callback runs against the per-build IConfigurationBuilder during Build(), before AddCommandLine is appended. The original args array is passed through in case the callback wants to use it.

AddDefaultConfigurationSources

For the conventional set, the builder exposes:

RequestHandlerBuilder<TReq, TRes> AddDefaultConfigurationSources();

It registers:

  • appsettings.json (optional)
  • appsettings.{ENV}.json (optional), where {ENV} is DOTNET_ENVIRONMENT or Production if unset
  • DOTNET_-prefixed environment variables
  • All environment variables

The Production fallback matches the .NET host convention — an unconfigured machine gets the locked-down configuration. Set DOTNET_ENVIRONMENT=Development on dev machines to load appsettings.Development.json. (Plumber v3 fell back to Development; see Migration.)

It deliberately excludes user secrets — those are a development-time convenience tied to a UserSecretsId that the builder can't infer for you. Call AddUserSecrets<T>() explicitly with a type from your assembly when you want them:

var builder = RequestHandlerBuilder
    .Create<MyRequest, MyResponse>(args)
    .AddDefaultConfigurationSources()
    .AddUserSecrets<Program>();

Command-line args still win — AddDefaultConfigurationSources doesn't append them, Build() does that last regardless.

Service registration

Service registrations also run during Build(). The callback receives the IServiceCollection and the already-built IConfiguration, so you can bind options, pick implementations, or read flags before deciding what to register:

builder.ConfigureServices((services, configuration) =>
{
    var options = configuration.GetSection("Tokenizer").Get<TokenizerOptions>()
        ?? TokenizerOptions.Defaults;

    services
        .AddSingleton(options)
        .AddSingleton<ITokenizer, WhitespaceTokenizer>();
});

You can call ConfigureServices more than once; the callbacks queue up and run in registration order during Build().

TimeProvider

A TimeProvider is registered automatically. The builder calls TryAddSingleton(TimeProvider.System) after your service callbacks run, so a TimeProvider you register yourself wins.

Register FakeTimeProvider (from Microsoft.Extensions.TimeProvider.Testing) when a test needs to control elapsed time and timer firing:

builder.ConfigureServices((services, _) =>
    services.AddSingleton<TimeProvider>(new FakeTimeProvider()));

Configuration lifetime

The built IConfiguration is registered with the service provider via factory registration, so the DI container owns its lifetime. Disposing the handler disposes the service provider, which transitively disposes the configuration root. Configuration is read once at Build(); Plumber does not watch files for changes (see Reloading configuration).

Logging

Logging is opt-in. The builder doesn't register any logging infrastructure unless you call ConfigureLogging at least once:

builder.ConfigureLogging(logging =>
{
    logging.SetMinimumLevel(LogLevel.Information);
    logging.AddSimpleConsole(o => o.SingleLine = true);
});

Internally, the first ConfigureLogging call causes services.AddLogging(...) to be invoked at Build() time; subsequent callbacks run inside the same AddLogging block. Skip the call entirely and ILogger<T> resolution falls through to the standard Microsoft.Extensions.Logging.Abstractions no-op behavior.

Build()

Two overloads:

RequestHandler<TReq, TRes> Build();                    // Timeout.InfiniteTimeSpan
RequestHandler<TReq, TRes> Build(TimeSpan timeout);    // per-request timeout

Build() walks through these steps in order:

  1. Copy the registered configuration sources into a fresh per-build IConfigurationBuilder (so the shared builder state isn't mutated across multiple Build() calls).
  2. Run every queued ConfigureConfiguration callback against the per-build builder.
  3. Append AddCommandLine(args) last so command-line values take precedence.
  4. Build the IConfiguration root.
  5. Register the built IConfiguration with the service collection (factory registration so the DI container owns its lifetime).
  6. Run every queued logging callback inside AddLogging, but only if at least one logging callback exists.
  7. Run every queued service callback against the service collection and the built configuration.
  8. TryAddSingleton(TimeProvider.System) — yours wins if you registered one.
  9. Construct a RequestHandler<TReq, TRes>, which builds the service provider.

The pipeline itself is not built yet. RequestHandler defers middleware composition until the first InvokeAsync call — that's when the order freezes, and that's when later Use calls start throwing InvalidOperationException. See Middleware for the details; the short version is "register all your middleware before the first invocation."

The timeout argument applies per-request. Every InvokeAsync call gets a timer driven by the registered TimeProvider, and exceeding it throws TimeoutException (distinguishable from caller cancellation). See Request lifecycle: Timeouts for the full timeout/cancellation interaction.

Disposal

RequestHandler<TRequest, TResponse> is IDisposable and IAsyncDisposable. Wrap it in using — or await using when registered services implement only IAsyncDisposable — so the service provider it built, along with scoped disposables and the IConfiguration itself, gets cleaned up:

using var handler = builder.Build(TimeSpan.FromSeconds(30));
var response = await handler.InvokeAsync(request);

The per-request DI scope is disposed asynchronously on every invocation, so scoped services that implement only IAsyncDisposable work without ceremony. DisposeAsync on the handler extends the same treatment to singletons.

Multiple Build() calls

A builder is a recipe. Each Build() call produces an independent handler with its own service provider and configuration root, both disposed when the handler is disposed. Useful when:

  • Different tests need fresh handlers with the same baseline configuration.
  • The same recipe is needed at multiple timeouts.
  • A long-running process wants to recycle handlers periodically.
var builder = Pipeline.CreateBuilder(args);

using var fast = builder.Build(TimeSpan.FromSeconds(1));
using var slow = builder.Build(TimeSpan.FromSeconds(60));

await fast.InvokeAsync(quickRequest);
await slow.InvokeAsync(slowRequest);

Both handlers share the same recipe but are completely independent at runtime. Disposing fast doesn't affect slow.

The internal copy-into-a-per-build-builder step in Build() exists to support exactly this — ConfigureConfiguration callbacks and the AddCommandLine append apply to a fresh per-build builder, leaving the shared recipe untouched across calls.

Reloading configuration

Plumber reads configuration once, at Build(), and builds the pipeline, the service provider, and bound options from it. It does not watch files for changes — a config edit takes effect on the next build, not in the running handler. That's deliberate: host-free workloads (Lambda, containers, CLIs) ship config changes as a new deployment, and an in-place reload that refreshed IConfiguration but left the already-built pipeline and singletons stale would be a split-brain.

When a long-running process genuinely needs to pick up changed config without a restart, rebuild and swap — a fresh Build() re-reads config from disk:

var handler = Pipeline.Build(args);

// on your own change signal (file watcher, SIGHUP, k8s ConfigMap, admin endpoint, poll):
var next = Pipeline.Build(args);          // re-reads config
var old = Interlocked.Exchange(ref handler, next);
old.Dispose();                            // swap at a quiescent point; not mid-request

You own the trigger and the swap, sized to your concurrency. The Configuration reload recipe is a complete worked example.

The CreateBuilder/Configure pattern

The convention Sample.Cli uses, and the one PlumberApplicationFactory<TReq, TRes> is shaped around:

internal static class Pipeline
{
    public static RequestHandlerBuilder<MyRequest, MyResponse> CreateBuilder(string[] args) =>
        RequestHandlerBuilder.Create<MyRequest, MyResponse>(args)
            .AddJsonFile("appsettings.json", optional: true)
            .ConfigureLogging(logging => logging.AddConsole())
            .ConfigureServices((services, configuration) =>
            {
                services.AddSingleton<IMyService, MyService>();
            });

    public static RequestHandler<MyRequest, MyResponse> Configure(
        RequestHandler<MyRequest, MyResponse> handler) =>
        handler
            .Use<ValidationMiddleware>()
            .Use<ProcessingMiddleware>();

    public static RequestHandler<MyRequest, MyResponse> Build(string[] args) =>
        Configure(CreateBuilder(args).Build());
}

In Program.cs:

using var handler = Pipeline.Build(args);
var response = await handler.InvokeAsync(request);

The two halves are independently usable:

  • Production code calls Pipeline.Build(args) and gets a fully-wired handler.
  • Tests instantiate PlumberApplicationFactory<TReq, TRes> with Pipeline.CreateBuilder and Pipeline.Configure, swap services through the factory's With* hooks, and let the factory call Build() and Configure for them.

Inlining everything works for one-file utilities. The split pays for itself the moment you write a test — see Testing for the full story on PlumberApplicationFactory<TReq, TRes> and the service-swapping hooks.

See also

  • MiddlewareUse overloads, delegate vs class middleware, method vs constructor injection
  • Request lifecycle — what RequestContext carries, timeouts, error handling
  • TestingPlumberApplicationFactory<TReq, TRes> and the CreateBuilder/Configure split
  • Advanced — host-mode handlers, custom TimeProvider, multiple Build() recipes

Clone this wiki locally