-
Notifications
You must be signed in to change notification settings - Fork 0
Migration
Major-version migrations for Plumber, newest first. Sections accumulate as new majors ship; older sections stay for historical reference.
v4 changes three behaviors; most code compiles unchanged.
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=DevelopmentOnly AddDefaultConfigurationSources() reads the variable — explicit AddJsonFile calls load whatever file you name. See Building a pipeline for the full source list.
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.
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.
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.
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();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>.
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.
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>();
});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.
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.
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 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.
Documents Plumber v4.x · Repository · MIT License · Report an issue
Getting Started
Pipeline (core)
Testing
Serilog Extensions
Diagnostics
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
- Configuration reload
Repo · NuGet · NuGet — Testing · NuGet — Serilog · NuGet — Diagnostics