-
Notifications
You must be signed in to change notification settings - Fork 0
Advanced
Three techniques you reach for once the basics aren't enough:
- Hosting Plumber inside a DI container someone else owns.
- Building a single recipe into multiple independent handlers.
- Replacing the
TimeProviderso tests can control the clock.
There's also a short note on the dispatch model — useful background for high-throughput pipelines.
The standalone RequestHandlerBuilder.Create<TReq, TRes>() path builds a handler that owns its own IServiceProvider. That's the right shape for a Lambda function or a CLI tool whose entire reason for existing is to run a Plumber pipeline.
When the pipeline is one piece of a larger application — an ASP.NET Core minimal API, a console app built around IHostBuilder, a worker service — there's already a DI root. Building a second one inside Plumber means duplicate registrations, duplicate IConfiguration, two of every singleton. The host-mode factory shares the existing one instead:
using Plumber;
using var handler = RequestHandler
.Create<MyRequest, MyResponse>(serviceProvider)
.Use<ValidationMiddleware>()
.Use<ProcessingMiddleware>();
var response = await handler.InvokeAsync(request);RequestHandler.Create is a static factory on the non-generic RequestHandler class. It returns the same RequestHandler<TRequest, TResponse> you'd get from a builder — the only difference is who owns the service provider.
The handler does not take ownership of the supplied IServiceProvider. When the handler is disposed:
- The handler marks itself disposed (so further
InvokeAsyncandUsecalls throwObjectDisposedException). - The provider is left untouched.
That's the right behavior — the host built the provider and the host disposes it. Wrapping the handler in using is still correct (it nulls the handler's internal references and prevents accidental reuse), but it doesn't tear down the host.
The supplied provider must support IServiceScopeFactory. Plumber creates a new DI scope per invocation; that's the mechanism behind method injection on InvokeAsync. Both of these produce providers with scope support:
-
services.BuildServiceProvider()(the default forServiceCollection). - Any provider built by a host (
Host.CreateApplicationBuilder().Build().Services,WebApplication.CreateBuilder().Build().Services, etc.).
If the provider lacks IServiceScopeFactory, the constructor throws InvalidOperationException immediately — you find out at handler-construction time, not at first invocation.
If the supplied provider has a TimeProvider registered, Plumber uses it for RequestContext.Elapsed and the timeout timer. Otherwise TimeProvider.System is used.
That means:
- A host that already registers
FakeTimeProviderfor tests will see Plumber pick it up automatically. - A host with no
TimeProviderregistration gets the system clock — which is what production wants.
The single-argument overload uses Timeout.InfiniteTimeSpan (no handler-level timeout). The two-argument overload takes one:
using var handler = RequestHandler
.Create<MyRequest, MyResponse>(serviceProvider, TimeSpan.FromSeconds(30))
.Use<MyMiddleware>();Caller-supplied cancellation tokens still work the same way. Behavior matches the standalone path: when the handler timeout elapses you get TimeoutException; when the caller's token cancels you get OperationCanceledException; if both fire, the caller wins.
Reach for RequestHandler.Create(IServiceProvider) when:
- An ASP.NET Core minimal API endpoint hands a request off to a Plumber pipeline.
- A console app built on
IHostBuilderruns background work through Plumber. - A worker service wants to share its host's services with a per-message pipeline.
- A test harness has already built a service provider for other reasons.
The flagship example is the ASP.NET Core host integration recipe; it walks through wiring this up end-to-end.
A RequestHandlerBuilder<TReq, TRes> is a recipe, not a singleton. Each call to Build() produces an independent handler with its own service provider, its own configuration root, its own everything. Same recipe, different runtime instances.
var builder = Pipeline.CreateBuilder(args);
using var handler1 = builder.Build();
using var handler2 = builder.Build();
// handler1 and handler2 share zero state.Two practical uses.
Test isolation matters. Building a new handler in each test guarantees no shared state between tests — no leftover registrations, no captured singletons, no per-test surprises.
[Fact]
public async Task FirstScenarioAsync()
{
using var handler = Pipeline.CreateBuilder(args)
.ConfigureServices((s, _) => s.AddSingleton<ITokenizer>(stubA))
.Build();
Pipeline.Configure(handler);
var report = await handler.InvokeAsync("input");
Assert.Equal(/* ... */);
}In practice, PlumberApplicationFactory handles fresh-per-test handlers and the WithServices swap for you. Direct Build() is the right tool when the factory is overkill.
Build() has an overload that takes a TimeSpan for the handler-wide timeout. Build the same recipe with different timeouts when you have callers with different latency budgets:
var builder = Pipeline.CreateBuilder(args);
using var fast = builder.Build(TimeSpan.FromSeconds(1));
using var slow = builder.Build(TimeSpan.FromSeconds(60));
// fast.InvokeAsync(...) throws TimeoutException after one second.
// slow.InvokeAsync(...) gets a full minute.Both handlers run the same middleware against the same configuration; only the timeout differs. Each call to Build() produces a fresh handler — the middleware list is empty until you Use something on each one (that part isn't shared by the builder).
Plumber resolves TimeProvider from the service collection at Build() time. The default registration is TimeProvider.System, which is what production wants. For tests, you usually want something else:
-
Deterministic
RequestContext.Elapsed— no flaky "took less than 50ms" assertions. -
Deterministic timeout firing — the timeout source is constructed from the registered
TimeProvider, so a fake time provider lets the test advance the clock past the timeout deliberately.
FakeTimeProvider lives in Microsoft.Extensions.TimeProvider.Testing. Register it in ConfigureServices:
using Microsoft.Extensions.Time.Testing;
var fakeTime = new FakeTimeProvider();
using var handler = RequestHandlerBuilder.Create<string, TextReport>()
.ConfigureServices((services, _) =>
{
services.RemoveAll<TimeProvider>();
services.AddSingleton<TimeProvider>(fakeTime);
})
.Build();
handler.Use<ProcessingMiddleware>();The RemoveAll<TimeProvider> is important — Plumber registers TimeProvider.System as a singleton during builder setup, so a plain AddSingleton<TimeProvider>(fakeTime) would lose to the existing registration depending on resolution order.
var fakeTime = new FakeTimeProvider();
using var handler = builder
.ConfigureServices((s, _) =>
{
s.RemoveAll<TimeProvider>();
s.AddSingleton<TimeProvider>(fakeTime);
})
.Build();
handler.Use(async (context, next) =>
{
await next(context);
// context.Elapsed is driven by the registered TimeProvider
});
var task = handler.InvokeAsync("input");
fakeTime.Advance(TimeSpan.FromMilliseconds(500));
var response = await task;var fakeTime = new FakeTimeProvider();
using var handler = builder
.ConfigureServices((s, _) =>
{
s.RemoveAll<TimeProvider>();
s.AddSingleton<TimeProvider>(fakeTime);
})
.Build(TimeSpan.FromSeconds(5));
handler.Use(async (context, next) =>
{
// returns only when the test cancels via the fake clock
await Task.Delay(Timeout.InfiniteTimeSpan, context.CancellationToken);
});
var task = handler.InvokeAsync("input");
fakeTime.Advance(TimeSpan.FromSeconds(5));
await Assert.ThrowsAsync<TimeoutException>(() => task);For the most common case — a factory test that wants FakeTimeProvider — the same registration goes through WithServices on PlumberApplicationFactory.
When you register a class middleware with handler.Use<TMiddleware>(), Plumber inspects the middleware's InvokeAsync method, walks its parameter list, and compiles a single delegate that:
- Takes the
RequestContextas input. - Resolves any extra
InvokeAsyncparameters fromcontext.Services(the per-request scope) viaGetRequiredService. - Invokes the middleware.
- Coalesces a null-returning
Taskinto an explicitInvalidOperationExceptionso silent bugs surface loudly.
That delegate is built once per Use<TMiddleware>() registration via Expression.Lambda(...).Compile() and cached on the registration. Every invocation thereafter calls the compiled delegate directly — no MethodInfo.Invoke, no per-call reflection allocation, no per-call parameter array.
The practical implication: middleware dispatch cost is roughly the cost of a virtual call plus one GetRequiredService per declared parameter. For high-throughput pipelines (Lambda functions billed by milliseconds, queue consumers processing thousands of messages per second), that's the cost worth paying attention to — and it's already paid for once at registration time, not per request.
Opting in takes nothing extra on your part. The compilation happens automatically when you call Use<TMiddleware>().
-
Recipe: ASP.NET Core host integration — the flagship use of
RequestHandler.Create(IServiceProvider) -
Testing — when and how to reach for
FakeTimeProvider -
PlumberApplicationFactory — the testing factory that wraps multiple
Build()calls andWithServices -
Building a pipeline — full builder surface, including the
Build(TimeSpan)overload
Documents Plumber v3.x · Repository · MIT License · Report an issue
Getting Started
Reference
- Building a pipeline
- Middleware
- Request lifecycle
- Testing
- PlumberApplicationFactory
- Advanced
- FAQ
- Migration
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