Skip to content

Migration

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

Migration

Major-version migrations for Plumber. Sections are appended as new majors ship; older sections stay for historical reference.

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, any file watchers the configuration registered (for example, AddJsonFile(..., reloadOnChange: true)), 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