MSL.Plumber.Pipeline.Testing 4.0.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package MSL.Plumber.Pipeline.Testing --version 4.0.0
                    
NuGet\Install-Package MSL.Plumber.Pipeline.Testing -Version 4.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MSL.Plumber.Pipeline.Testing" Version="4.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MSL.Plumber.Pipeline.Testing" Version="4.0.0" />
                    
Directory.Packages.props
<PackageReference Include="MSL.Plumber.Pipeline.Testing" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MSL.Plumber.Pipeline.Testing --version 4.0.0
                    
#r "nuget: MSL.Plumber.Pipeline.Testing, 4.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MSL.Plumber.Pipeline.Testing@4.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MSL.Plumber.Pipeline.Testing&version=4.0.0
                    
Install as a Cake Addin
#tool nuget:?package=MSL.Plumber.Pipeline.Testing&version=4.0.0
                    
Install as a Cake Tool

.NET Tests .NET Publish NuGet Nuget

Plumber MSL Armory

Plumber

Another weapon from the MSL Armory

Middleware pipelines for host-free .NET projects

Plumber gives console apps, Lambdas, queue consumers, and other host-free .NET projects the same middleware-pipeline shape that ASP.NET Core gives web apps. You define a request type, a response type, and a chain of middleware components. Plumber wires up DI, configuration, logging, scoping, timeouts, and cancellation; you focus on the steps in your pipeline.

Upgrading from v3.x? Three changes in v4: AddDefaultConfigurationSources() defaults the environment to Production, disposal is async-aware (IAsyncDisposable on the handler and test factory), and reloadOnChange is removed (rebuild and swap the handler instead). See Migration v3.x → v4.x.

Upgrading from v2.x? Many APIs changed in v3 — interfaces removed, configuration no longer auto-loaded, builder reshaped. See Migration v2.x → v3.x at the bottom. v3 is also a modernization and bug-fix pass: faster middleware dispatch (expression-tree-compiled), monotonic Elapsed, distinguishable timeout exceptions, and a host-mode factory for reusing an existing DI container.

Table of Contents

When to reach for Plumber

  • Console apps and CLI tools that need ordered, composable steps with DI and config
  • AWS Lambda functions (API Gateway requests, SQS/SNS events, DynamoDB Streams, EventBridge)
  • Queue consumers (RabbitMQ, Kafka, Azure Service Bus)
  • File and ETL processors
  • Any pipeline you'd reach for ASP.NET Core middleware in, but without the web host

A project that already has a host (ASP.NET Core or generic host) gets its pipeline from the host; reach for Plumber inside it when you want a typed, non-HTTP pipeline sharing the same DI container — see Hosting inside an existing DI container.

The pipeline shape is borrowed from Steve Gordon's walkthrough of the ASP.NET Core middleware pipeline: How is the ASP.NET Core Middleware Pipeline Built. If you're new to middleware, Microsoft has a primer in their docs.

Installation

dotnet add package MSL.Plumber.Pipeline

Plumber targets .NET 10.

Hello, World

The smallest working pipeline:

using Plumber;

using var handler = RequestHandlerBuilder
    .Create<string, string>()
    .Build();

handler.Use((context, next) =>
{
    context.Response = $"Hello, {context.Request}!";
    return next(context);
});

var greeting = await handler.InvokeAsync("World");
Console.WriteLine(greeting); // Hello, World!

That's the whole shape: a builder, a built handler, one or more middleware, and an InvokeAsync call. Each invocation gets its own DI scope and cancellation token.

RequestHandler<TRequest, TResponse> is IDisposable and IAsyncDisposable — wrap it in using, or await using when you register services that implement only IAsyncDisposable, to dispose the service provider it builds.

Pipeline architecture

Middleware in Plumber forms an onion: code before await next(context) runs in registration order, code after runs in reverse. A request travels inward; the response travels outward.

sequenceDiagram
    participant Caller
    participant MW1 as Middleware 1
    participant MW2 as Middleware 2
    participant MW3 as Middleware 3

    Caller->>+MW1: request
    Note over MW1: pre-processing
    MW1->>+MW2: next(context)
    Note over MW2: pre-processing
    MW2->>+MW3: next(context)
    Note over MW3: pre-processing
    MW3-->>-MW2: return
    Note over MW2: post-processing
    MW2-->>-MW1: return
    Note over MW1: post-processing
    MW1-->>-Caller: response

Three rules:

  1. Middleware runs in the order you register it.
  2. Anything before await next(context) runs going in. Anything after runs coming back.
  3. Skip next and the pipeline short-circuits — useful for validation, caching, and authorization.

Building a pipeline

A typical Plumber pipeline has two halves:

  1. Builder configuration — registers configuration sources, services, and logging.
  2. Pipeline configuration — adds middleware to the built handler.

Splitting these into two methods makes the pipeline testable (see Testing your pipeline).

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

This is the convention Sample.Cli uses. Inlining everything works until the first test — adopt the split early.

Configuration sources

v3 configuration is opt-in: only command-line args load automatically, appended last so they always win. Pick the sources you want:

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

Plumber doesn't watch files for changes. Config is read once, at Build(). To pick up changed config in a long-running process, rebuild the handler from the recipe and swap it — see Reloading configuration without a restart.

A callback exposes the full IConfigurationBuilder surface for everything else:

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

If you want the conventional set (appsettings.json, appsettings.{env}.json, DOTNET_* env vars, all env vars), call:

builder.AddDefaultConfigurationSources();

{env} comes from DOTNET_ENVIRONMENT and defaults to Production when unset — the same convention the .NET host uses. Set DOTNET_ENVIRONMENT=Development on your dev machine to load appsettings.Development.json.

User secrets stay out of the conventional set — call AddUserSecrets<T>() explicitly with a type from your assembly when you want them.

Service registration

Service registration runs at Build() time and gets the built IConfiguration so you can bind options or pick implementations:

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

A TimeProvider is registered automatically (defaulting to TimeProvider.System); register your own if you want to control timer firing in tests — see Custom TimeProvider for tests.

Logging

Logging is opt-in; ConfigureLogging registers the infrastructure.

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

Middleware

Middleware is a piece of work that runs against a RequestContext<TRequest, TResponse>. It chooses whether to call next(context) (continue) or short-circuit by setting context.Response and returning.

Delegate middleware

For one-off transformations, register an inline delegate:

handler.Use(async (context, next) =>
{
    context.ThrowIfCanceled();

    var stopwatch = Stopwatch.StartNew();
    await next(context);
    stopwatch.Stop();

    Console.WriteLine($"{context.Id} took {stopwatch.ElapsedMilliseconds}ms");
});

Class middleware

For middleware with dependencies, write a class. Plumber recognizes it by convention: a constructor whose first parameter is RequestMiddleware<TRequest, TResponse> next, and a public Task InvokeAsync method whose first parameter is RequestContext<TRequest, TResponse>.

internal sealed class NormalizeMiddleware(RequestMiddleware<string, TextReport> next)
{
    public Task InvokeAsync(RequestContext<string, TextReport> context)
    {
        context.ThrowIfCanceled();
        context.Data["normalized"] = context.Request.ToLowerInvariant();
        return next(context);
    }
}

Register with handler.Use<NormalizeMiddleware>().

The terminal middleware at the end of the pipeline already checks cancellation before invoking, so the explicit ThrowIfCanceled calls above are defense-in-depth — worthwhile in long-running middleware that works before deferring to next; short middleware can skip them. To short-circuit without throwing, check context.IsCanceled and set context.Response yourself.

You can declare additional InvokeAsync parameters. Plumber resolves them from the per-request scope on every invocation — this is the safe place for DbContext, HttpClient, and other scoped or transient services.

internal sealed class TokenizeMiddleware(RequestMiddleware<string, TextReport> next)
{
    public Task InvokeAsync(
        RequestContext<string, TextReport> context,  // first param must be the context
        ITokenizer tokenizer)                         // resolved from context.Services on every request
    {
        context.ThrowIfCanceled();
        context.Data["tokens"] = tokenizer.Tokenize(context.Request);
        return next(context);
    }
}

The dispatch compiles to an expression tree once per registration; every invocation calls the compiled lambda.

Constructor injection (advanced — singleton lifetime, root provider)

Constructor parameters after next are resolved from the root IServiceProvider, not the per-request scope. Plumber constructs the middleware once at registration and reuses that instance for every request — effectively a singleton, regardless of how the dependency is registered.

Don't inject scoped or transient services via the constructor. The captured instance is shared across all requests; you'll get stale data, thread-safety violations, or ObjectDisposedException from disposed dependencies. Use method injection on InvokeAsync instead.

Constructor injection is appropriate when the dependency is genuinely a singleton — ILogger<T>, TimeProvider, an options instance bound from configuration:

internal sealed class LoggingMiddleware(
    RequestMiddleware<string, TextReport> next,
    ILogger<LoggingMiddleware> logger)
{
    public async Task InvokeAsync(RequestContext<string, TextReport> context)
    {
        logger.LogInformation("processing {Id}", context.Id);
        await next(context);
        logger.LogInformation(
            "completed {Id} in {Elapsed}ms",
            context.Id,
            context.Elapsed.TotalMilliseconds);
    }
}

You can also pass extra constructor arguments at registration. Declare the constructor with next first, your extra parameters next, then any DI-resolved dependencies. ActivatorUtilities matches the supplied arguments by type before satisfying the rest from the root provider:

handler.Use<RetryMiddleware>(3, TimeSpan.FromMilliseconds(200));

Request lifecycle

Sharing data between middleware

The RequestContext.Data dictionary lets middleware pass values down the chain without modifying the request or response:

handler.Use((context, next) =>
{
    context.Data["user.id"] = AuthenticateAndExtractUserId(context.Request);
    return next(context);
});

handler.Use((context, next) =>
{
    if (context.TryGetValue<string>("user.id", out var userId))
    {
        // ...
    }
    return next(context);
});

TryGetValue<T> returns true only when a non-null T sits at the key — missing keys, null values, and type mismatches all return false. The check is value is T, so a stored 0 for an int key still returns true.

The dictionary allocates lazily on first access; pipelines that share no data pay no allocation cost.

Short-circuiting

Skip next and the pipeline short-circuits — the canonical pattern for validation, caching, and authorization:

internal sealed class ValidationMiddleware(RequestMiddleware<string, TextReport> next)
{
    public Task InvokeAsync(RequestContext<string, TextReport> context)
    {
        context.ThrowIfCanceled();

        if (string.IsNullOrWhiteSpace(context.Request))
        {
            context.Response = new TextReport(
                Original: context.Request ?? string.Empty,
                Normalized: string.Empty,
                Tokens: [],
                WordCount: 0,
                Elapsed: TimeSpan.Zero,
                ErrorMessage: "input must be non-empty");
            return Task.CompletedTask; // short-circuit: no next() call
        }

        return next(context);
    }
}

Middleware registered earlier than this still observes the short-circuit on the way out — code after their own await next(context) runs normally with context.Response already populated.

Pipelines with no response: Unit

Some pipelines exist purely to do work — event handlers, queue consumers, notifications. Unit is Plumber's name for "no meaningful response":

public readonly record struct Unit;

Use it as TResponse:

using var handler = RequestHandlerBuilder
    .Create<MessageBatch, Unit>()
    .Build()
    .Use<ValidateMiddleware>()
    .Use<ProcessMiddleware>();

await handler.InvokeAsync(batch);

Unit is borrowed from F# (unit) and Haskell (()). It's more expressive than object? and keeps every handler typed as RequestHandler<TRequest, TResponse>.

Timeouts

Two timeout layers: the handler has a built-in timeout configured at Build(), and callers can layer a deadline of their own with a CancellationToken.

Handler-wide:

using var handler = builder.Build(TimeSpan.FromSeconds(30));

Caller-supplied:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var response = await handler.InvokeAsync(request, cts.Token);

When the handler timeout elapses, InvokeAsync throws TimeoutException. When the caller's token cancels, it throws OperationCanceledException. If both fire, the caller wins. The parameterless InvokeAsync(request) overload skips the caller layer — the handler timeout is the only cancellation signal in flight. The timer is driven by the registered TimeProvider, so FakeTimeProvider works in tests.

Error handling

Exceptions propagate through the pipeline by default. Wrap a try/catch at the outer edge if you want to convert or log them:

internal sealed class ErrorBoundary<TReq, TRes>(
    RequestMiddleware<TReq, TRes> next,
    ILogger<ErrorBoundary<TReq, TRes>> logger)
    where TReq : notnull
{
    public async Task InvokeAsync(RequestContext<TReq, TRes> context)
    {
        try
        {
            await next(context);
        }
        catch (OperationCanceledException)
        {
            logger.LogWarning("request {Id} was cancelled", context.Id);
            throw;
        }
        catch (TimeoutException)
        {
            logger.LogWarning("request {Id} timed out", context.Id);
            throw;
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "request {Id} failed", context.Id);
            throw;
        }
    }
}

Register the boundary first so it sees every exception in the pipeline. The class is generic, so spell out the closed generic when you register it:

handler.Use<ErrorBoundary<MyRequest, MyResponse>>();

Testing your pipeline (preview)

Preview — Plumber.Testing ships in the source tree ahead of its NuGet release. Take a project reference until it publishes; once published, it becomes the recommended way to test pipelines.

Plumber.Testing ships a PlumberApplicationFactory<TRequest, TResponse> modeled on ASP.NET Core's WebApplicationFactory<TEntryPoint>. It builds your real pipeline once per test, lets you swap services or configuration, and disposes everything when the test ends.

using Plumber.Testing;

public sealed class PipelineTests
{
    [Fact]
    public async Task ValidInputProducesReportAsync()
    {
        using var factory = new PlumberApplicationFactory<string, TextReport>(
            Pipeline.CreateBuilder,
            Pipeline.Configure);

        var report = await factory.InvokeAsync("Hello, World!");

        Assert.NotNull(report);
        Assert.Equal("hello, world!", report.Normalized);
    }

    [Fact]
    public async Task StubTokenizerAsync()
    {
        using var factory = new PlumberApplicationFactory<string, TextReport>(
                Pipeline.CreateBuilder,
                Pipeline.Configure)
            .WithServices(services =>
                services.AddSingleton<ITokenizer>(new StubTokenizer(["a", "b", "c"])));

        var report = await factory.InvokeAsync("anything");

        Assert.Equal(3, report!.WordCount);
    }
}

Customization hooks:

  • WithBuilder(Action<RequestHandlerBuilder<TReq,TRes>>) — escape hatch; full access to the builder
  • WithServices(Action<IServiceCollection>) — swap or add services
  • WithServices(Action<IServiceCollection, IConfiguration>) — same, with IConfiguration available
  • WithLogging(Action<ILoggingBuilder>) — adjust logging
  • WithConfiguration(Action<IConfigurationBuilder>) — add config sources
  • WithInMemorySettings(IEnumerable<KeyValuePair<string, string?>>) — seed config keys

CreateHandler() is idempotent — every call returns the same handler. The first call freezes the builder hooks; adding more throws.

Services exposes the built pipeline's root IServiceProvider for assertions — resolve singletons directly, or CreateScope() first for scoped services like a DbContext. Accessing it builds the handler, freezing the hooks just like CreateHandler():

using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
Assert.Equal(2, await db.Records.CountAsync());

Asserting pipeline composition

RequestHandler<TRequest, TResponse>.Middleware exposes one MiddlewareDescriptor per registration, in registration order — which is also inbound execution order. Use it to assert that your pipeline is wired in the order you expect, from registration metadata alone:

[Fact]
public void PipelineRegistersMiddlewareInOrder()
{
    using var factory = new PlumberApplicationFactory<string, TextReport>(
        Pipeline.CreateBuilder,
        Pipeline.Configure);

    Assert.Collection(
        factory.CreateHandler().Middleware,
        m => Assert.Equal(typeof(ValidationMiddleware), m.MiddlewareType),
        m => Assert.Equal(typeof(NormalizeMiddleware), m.MiddlewareType),
        m => Assert.Equal(typeof(TokenizeMiddleware), m.MiddlewareType));
}

Class-based registrations (Use<T>()) carry the middleware type in MiddlewareType. Delegate-based registrations have a null type; their DisplayName is the method name for method groups and MiddlewareDescriptor.DelegateDisplayName ("<delegate>") for lambdas, so a lambda slot asserts by name:

m => Assert.Equal(MiddlewareDescriptor.DelegateDisplayName, m.DisplayName)

The descriptors are metadata only: the component delegates and the compiled pipeline stay private.

Sample app

Sample.Cli is a complete, working version of the same shape. It's a small CLI that reads stdin (or argv), runs it through validation → normalization → tokenization → reporting, and prints the result. The earlier README snippets are simplified for teaching — the sample's middleware add logging and use shared DataKeys constants for the context.Data keys. It demonstrates:

  • The CreateBuilder + Configure split
  • Configuration via ConfigureConfiguration and bound configuration POCOs
  • DI-registered services (ITokenizer)
  • Method injection on class middleware
  • Structured logging via ConfigureLogging
  • A timing wrapper that uses record with to enrich the response

Sample.Cli.Tests shows both direct testing of the built pipeline and the PlumberApplicationFactory pattern.

Advanced

Hosting inside an existing DI container

If your application already has a built IServiceProvider — an ASP.NET Core host, a generic host, or any other container — you can build a Plumber handler that reuses that provider instead of creating its own:

using var handler = RequestHandler
    .Create<MyRequest, MyResponse>(serviceProvider)
    .Use<MyMiddleware1>()
    .Use<MyMiddleware2>();

var response = await handler.InvokeAsync(request);

The handler leaves ownership with you: disposing it leaves your provider untouched. The provider must support IServiceScopeFactory (any provider built from ServiceCollection.BuildServiceProvider or a host already does) — Plumber needs it to create the per-request scope.

A TimeProvider registered in the provider drives Elapsed and timeouts; absent one, the handler falls back to TimeProvider.System.

This is the path to take when you want a Plumber pipeline inside an ASP.NET Core minimal API, an existing console app with IHostBuilder, or any other context that already owns a DI root.

Multiple Build() calls

A builder is a recipe; each Build() produces an independent handler with its own service provider and configuration root. Use this to spin up a fresh handler per test, or to vary the timeout per build:

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

Both handlers share the same recipe but are independent at runtime.

Custom TimeProvider for tests

The handler resolves TimeProvider from the service collection. Register your own to control elapsed time and timer firing in tests:

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

FakeTimeProvider lives in Microsoft.Extensions.TimeProvider.Testing.

Reloading configuration without a restart

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 a deliberate fit for how host-free workloads deploy (Lambda, containers, CLIs): config changes ship as a new deployment.

When a long-running process genuinely needs to pick up changed config without a restart, the owner rebuilds a fresh handler from the recipe and swaps it — 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; don't dispose mid-request

You own the trigger and the swap, sized to your concurrency model. The wiki Configuration reload recipe walks through a complete example.

FAQ

How does Plumber compare to ASP.NET Core middleware?

Same shape, different host. Plumber's RequestContext<TRequest, TResponse> is the typed analogue of HttpContext; the Use overloads, the onion execution model, and the per-request DI scope all behave the same way.

Can I use Plumber alongside ASP.NET Core?

Yes — see Hosting inside an existing DI container. It's useful when you have a non-HTTP pipeline (a background worker, a queue handler) that should share the host's services.

My class middleware doesn't run — what's wrong?

Common causes: an earlier middleware short-circuited (didn't call next), an exception was thrown earlier in the pipeline, or your class signature doesn't match the convention. Plumber expects RequestMiddleware<TReq, TRes> next as the first constructor parameter (it's passed positionally first) and requires RequestContext<TReq, TRes> as the first InvokeAsync parameter; the InvokeAsync method must be public and return a Task.

Why isn't my appsettings.json loaded?

v3 doesn't auto-load configuration. Call AddJsonFile("appsettings.json", optional: true) (or AddDefaultConfigurationSources() for the conventional set) explicitly. See Configuration sources.

Can I add middleware after the pipeline has been invoked?

No. The first call to InvokeAsync builds the pipeline; further Use calls throw InvalidOperationException. Configure all your middleware before your first invocation.

Migration v3.x → v4.x

v4 changes two behaviors; most code compiles unchanged.

1. AddDefaultConfigurationSources defaults to Production

v3 loaded appsettings.Development.json when DOTNET_ENVIRONMENT was unset. v4 defaults to Production, matching the .NET host convention — an unconfigured machine gets the locked-down configuration, and developers opt in to dev settings.

Set the variable on machines that should keep loading Development config:

export DOTNET_ENVIRONMENT=Development

Only AddDefaultConfigurationSources() reads the variable — explicit AddJsonFile calls load whatever file you name.

2. Disposal is async-aware

RequestHandler<TRequest, TResponse> and PlumberApplicationFactory<TRequest, TResponse> implement IAsyncDisposable alongside IDisposable, and the per-request DI scope is disposed asynchronously. Services that implement only IAsyncDisposable now dispose correctly; in v3 they threw InvalidOperationException at scope or provider teardown.

// v3
using var handler = builder.Build();
// v4 — prefer await using in async contexts
await using var handler = builder.Build();

using remains valid when every registered disposable implements IDisposable.

3. reloadOnChange support removed

Plumber no longer watches configuration files. The reloadOnChange parameter is gone from AddJsonFile (the three-arg overload), AddUserSecrets, and AddDefaultConfigurationSources. In-place file-watching reloaded IConfiguration but left the pipeline, provider, and bound options built-once (a split-brain), and it doesn't fit how host-free workloads deploy.

// v3
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
// v4 — drop the argument
.AddJsonFile("appsettings.json", optional: true)

To pick up changed config in a long-running process, rebuild and swap the handler — see Reloading configuration without a restart.

Migration v2.x → v3.x

v3 reshapes the public API around concrete types and explicit configuration. The migrations below cover the common cases.

1. Interfaces removed

Both IRequestHandlerBuilder<TRequest, TResponse> and IRequestHandler<TRequest, TResponse> are gone. Type your variables and parameters with the concrete classes instead.

// v2
IRequestHandlerBuilder<MyReq, MyRes> builder = RequestHandlerBuilder.Create<MyReq, MyRes>();
IRequestHandler<MyReq, MyRes> handler = builder.Build();
// v3
RequestHandlerBuilder<MyReq, MyRes> builder = RequestHandlerBuilder.Create<MyReq, MyRes>();
RequestHandler<MyReq, MyRes> handler = builder.Build();

2. VoidUnit

The no-response type was renamed:

// v2
RequestHandlerBuilder.Create<SqsEvent, Void>();
// v3
RequestHandlerBuilder.Create<SqsEvent, Unit>();

3. Configuration is no longer auto-loaded

v2 implicitly added appsettings.json, environment variables, and user secrets. v3 doesn't:

// v2 — implicit
var builder = RequestHandlerBuilder.Create<TReq, TRes>(args);
// v3 — explicit; either pick sources individually
var builder = RequestHandlerBuilder.Create<TReq, TRes>(args)
    .AddJsonFile("appsettings.json", optional: true)
    .AddEnvironmentVariables();

// or opt back into the conventional set
var builder = RequestHandlerBuilder.Create<TReq, TRes>(args)
    .AddDefaultConfigurationSources();

AddDefaultConfigurationSources() leaves user secrets out — call AddUserSecrets<T>() explicitly.

4. Services and Configuration properties → callbacks

The builder no longer exposes mutable Services and Configuration properties. Use the Configure* callbacks; they run at Build() time, with the built IConfiguration available where appropriate.

// v2
var builder = RequestHandlerBuilder.Create<TReq, TRes>();
builder.Services.AddSingleton<IMyService, MyService>();
builder.Configuration.AddInMemoryCollection(...);
// v3
var builder = RequestHandlerBuilder.Create<TReq, TRes>()
    .AddInMemoryCollection(...)
    .ConfigureServices((services, configuration) =>
    {
        services.AddSingleton<IMyService, MyService>();
    });

5. Scoped or transient services in middleware ctors → method injection

v2 let you inject anything into a middleware constructor. v3 still does, but constructor parameters resolve from the root provider, and the middleware constructs once at registration time — a captured scoped or transient service is shared across every request. Inject those through InvokeAsync method parameters instead.

// v2 — works, but the DbContext is captured in the singleton middleware
internal sealed class SaveMiddleware(
    RequestMiddleware<TReq, TRes> next,
    AppDbContext db)
{
    public async Task InvokeAsync(RequestContext<TReq, TRes> context)
    {
        await db.SaveAsync(context.Request);
        await next(context);
    }
}
// v3 — DbContext is resolved fresh from the per-request scope
internal sealed class SaveMiddleware(RequestMiddleware<TReq, TRes> next)
{
    public async Task InvokeAsync(
        RequestContext<TReq, TRes> context,
        AppDbContext db)
    {
        await db.SaveAsync(context.Request);
        await next(context);
    }
}

6. Timeout exceptions are distinguishable

v2 surfaced both handler timeouts and caller cancellation as OperationCanceledException. v3 throws TimeoutException for handler timeouts and OperationCanceledException for caller cancellation. Update any catch clauses that distinguished them by message:

// v2
catch (OperationCanceledException ex)
{
    if (ex.Message.Contains("timeout")) { /* ... */ }
}
// v3
catch (TimeoutException) { /* handler timeout */ }
catch (OperationCanceledException) { /* caller cancellation */ }

7. Handler is IDisposable

Always wrap the handler in using. The handler owns the service provider it built — leaking it leaks the provider, the IConfiguration root, and any IDisposable services.

// v2
var handler = builder.Build();
var response = await handler.InvokeAsync(request);
// v3
using var handler = builder.Build();
var response = await handler.InvokeAsync(request);

The exception is host-mode handlers built via RequestHandler.Create(IServiceProvider) — those don't own the provider and don't dispose it; the wrapping using only marks the handler itself disposed.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
5.1.0 94 6/14/2026
5.0.0 96 6/13/2026
4.0.0 94 6/13/2026