-
Notifications
You must be signed in to change notification settings - Fork 0
Building A Pipeline
A typical Plumber pipeline has two halves:
-
Builder configuration — registers configuration sources, services, and logging on a
RequestHandlerBuilder<TRequest, TResponse>. -
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.
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.
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 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.
For the conventional set, the builder exposes:
RequestHandlerBuilder<TReq, TRes> AddDefaultConfigurationSources();It registers:
-
appsettings.json(optional) -
appsettings.{ENV}.json(optional), where{ENV}isDOTNET_ENVIRONMENTorProductionif 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 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().
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()));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 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.
Two overloads:
RequestHandler<TReq, TRes> Build(); // Timeout.InfiniteTimeSpan
RequestHandler<TReq, TRes> Build(TimeSpan timeout); // per-request timeoutBuild() walks through these steps in order:
- Copy the registered configuration sources into a fresh per-build
IConfigurationBuilder(so the shared builder state isn't mutated across multipleBuild()calls). - Run every queued
ConfigureConfigurationcallback against the per-build builder. - Append
AddCommandLine(args)last so command-line values take precedence. - Build the
IConfigurationroot. - Register the built
IConfigurationwith the service collection (factory registration so the DI container owns its lifetime). - Run every queued logging callback inside
AddLogging, but only if at least one logging callback exists. - Run every queued service callback against the service collection and the built configuration.
-
TryAddSingleton(TimeProvider.System)— yours wins if you registered one. - 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.
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.
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.
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-requestYou own the trigger and the swap, sized to your concurrency. The Configuration reload recipe is a complete worked example.
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>withPipeline.CreateBuilderandPipeline.Configure, swap services through the factory'sWith*hooks, and let the factory callBuild()andConfigurefor 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.
-
Middleware —
Useoverloads, delegate vs class middleware, method vs constructor injection -
Request lifecycle — what
RequestContextcarries, timeouts, error handling -
Testing —
PlumberApplicationFactory<TReq, TRes>and theCreateBuilder/Configuresplit -
Advanced — host-mode handlers, custom
TimeProvider, multipleBuild()recipes
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