-
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, but 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. The same shape is what Sample.Cli ships with.
This page is a reference for the builder surface. For the pipeline half — the Use overloads on the built handler — see 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.
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);
RequestHandlerBuilder<TReq, TRes> AddJsonFile(string path, bool optional, bool reloadOnChange);
// 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;
RequestHandlerBuilder<TReq, TRes> AddUserSecrets<T>(bool optional, bool reloadOnChange) 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") ?? "Development";
var builder = RequestHandlerBuilder
.Create<MyRequest, MyResponse>(args)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile($"appsettings.{env}.json", optional: true, reloadOnChange: 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, reload on change) -
appsettings.{ENV}.json(optional, reload on change), where{ENV}isDOTNET_ENVIRONMENTorDevelopmentif unset -
DOTNET_-prefixed environment variables - All environment variables
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 and any file watchers it created (for example, AddJsonFile(..., reloadOnChange: true)).
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 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.
RequestHandler<TRequest, TResponse> is IDisposable. Always wrap it in using so the service provider it built — and any file watchers, scoped disposables, or the IConfiguration itself — gets cleaned up:
using var handler = builder.Build(TimeSpan.FromSeconds(30));
var response = await handler.InvokeAsync(request);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. This is 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.
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.
-
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