Skip to content

Advanced

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

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 TimeProvider so tests can control the clock.

There's also a short note on the dispatch model — useful background for high-throughput pipelines.

Hosting inside an existing DI container

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.

Ownership and disposal

The handler does not take ownership of the supplied IServiceProvider. When the handler is disposed:

  • The handler marks itself disposed (so further InvokeAsync and Use calls throw ObjectDisposedException).
  • 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.

Service provider requirements

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 for ServiceCollection).
  • 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.

TimeProvider resolution

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 FakeTimeProvider for tests will see Plumber pick it up automatically.
  • A host with no TimeProvider registration gets the system clock — which is what production wants.

Custom timeout

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.

When this is the right path

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 IHostBuilder runs 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.

Multiple Build() calls

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.

A fresh handler per test

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.

Varying timeout per build

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).

Custom TimeProvider for tests

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.

Worked example: Elapsed

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;

Worked example: timeout firing

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.

Performance note: expression-tree-compiled middleware dispatch

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:

  1. Takes the RequestContext as input.
  2. Resolves any extra InvokeAsync parameters from context.Services (the per-request scope) via GetRequiredService.
  3. Invokes the middleware.
  4. Coalesces a null-returning Task into an explicit InvalidOperationException so 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>().

See also

Clone this wiki locally