Skip to content

Migration

Mark Lauter edited this page Jun 13, 2026 · 4 revisions

Migration

Major-version migrations for Plumber, newest first. Sections accumulate as new majors ship; older sections stay for historical reference.

v3.x to v4.x

v4 changes three behaviors; most code compiles unchanged.

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. See Building a pipeline for the full source list.

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. Host-mode handlers built via RequestHandler.Create(IServiceProvider) keep their ownership contract — Dispose and DisposeAsync both leave the externally-owned provider untouched.

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 — a fresh Build() re-reads config. See the Configuration reload recipe and Building a pipeline → Reloading configuration.

v2.x to v3.x

v3 reshapes the public API around concrete types and explicit configuration, modernizes the dispatch path (expression-tree compiled), and makes timeout exceptions distinguishable from caller cancellation.

The migrations below cover the common cases.

Interfaces removed

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

Void to Unit

The no-response type was renamed.

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

Unit is a readonly record struct borrowed from F# and Haskell. It lets pipelines with no meaningful response stay typed uniformly as RequestHandler<TRequest, TResponse>.

Configuration is no longer auto-loaded

v3 makes configuration opt-in. The only thing appended automatically is args (via AddCommandLine, last so it always wins).

// v2 — implicit
var builder = RequestHandlerBuilder.Create<TReq, TRes>(args);
// v3 — explicit; 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();

v2 implicitly added appsettings.json, environment variables, and user secrets. AddDefaultConfigurationSources() excludes user secrets — call AddUserSecrets<T>() explicitly with a type from your assembly when you want them.

Services and Configuration properties to 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>();
    });

Use method injection on InvokeAsync for scoped or transient services

Middleware constructor parameters are now resolved from the root provider, and the middleware itself is constructed once at registration time.

For scoped or transient services (such as DbContext or HttpClient), use method injection on InvokeAsync — those parameters are resolved from the per-request scope on every invocation.

// v2 — DbContext was 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);
    }
}

Constructor injection remains the right choice for genuinely-singleton dependencies — ILogger<T>, TimeProvider, an options instance bound from configuration. The class is constructed once at registration time and reused; the captured dependency is shared across all requests, which matches the singleton lifetime. See Middleware for the full lifetime story.

Timeout exceptions are distinguishable

v3 throws TimeoutException for handler timeouts and OperationCanceledException for caller cancellation. If both fire, caller cancellation wins.

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 */ }

v2 surfaced both cases as OperationCanceledException, so callers had to inspect the message to tell them apart.

Handler is IDisposable

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 reuse an externally-owned provider and leave it untouched on Dispose. The wrapping using only marks the handler itself disposed.

Future migrations

Future major-version migrations will be appended below as new sections; v2 to v3 stays at the top for now since it is the only one.

See also

Clone this wiki locally