-
Notifications
You must be signed in to change notification settings - Fork 0
Middleware
A middleware is a unit of work in the pipeline. It receives the RequestContext<TRequest, TResponse> — the per-call package carrying the request, response slot, scoped services, and cancellation token — does something with it, and either calls next(context) to continue the chain or returns without calling next to short-circuit the rest of the pipeline.
The execution model — onion shape, next delegate, per-request DI scope — is covered in Concepts.
Middleware comes in two flavors:
- A delegate registered inline — best for one-off transformations with no dependencies.
- A class registered by type — best when you need DI, want to test the middleware in isolation, or expect to share it across pipelines.
RequestMiddleware<TRequest, TResponse> is the underlying delegate type:
public delegate Task RequestMiddleware<TRequest, TResponse>(
RequestContext<TRequest, TResponse> context)
where TRequest : notnull;Every middleware in the pipeline — delegate or class — is ultimately compiled down to one of these. The "next" middleware passed to your code is itself a RequestMiddleware<TRequest, TResponse>; calling it advances the chain by one step.
The pipeline is built right-to-left at first invocation: each registered component wraps the next-in-line into a single delegate that the handler invokes.
Two Use overloads accept delegates:
RequestHandler<TReq, TRes> Use(
Func<RequestMiddleware<TReq, TRes>, RequestMiddleware<TReq, TRes>> middleware);
RequestHandler<TReq, TRes> Use(
Func<RequestContext<TReq, TRes>, RequestMiddleware<TReq, TRes>, Task> middleware);The second overload is the one you'll use almost always — (context, next) => ...:
handler.Use(async (context, next) =>
{
context.ThrowIfCanceled();
var stopwatch = Stopwatch.StartNew();
await next(context);
stopwatch.Stop();
Console.WriteLine($"{context.Id} took {stopwatch.ElapsedMilliseconds}ms");
});The first overload — next => context => ... — exposes the lower-level shape and is occasionally useful when you want to capture state outside the per-call closure or when you're composing middleware programmatically.
Delegate middleware is the right choice for adapters and one-off transformations: a quick header rewrite, a logging line, a stopwatch wrapper around the rest of the pipeline. Reach for class middleware as soon as the work has dependencies or is worth testing separately.
Plumber recognizes a middleware class by its shape, not by an attribute or interface:
- The constructor's first parameter must be
RequestMiddleware<TRequest, TResponse> next. - There must be a
public Task InvokeAsync(...)method whose first parameter isRequestContext<TRequest, TResponse>.
A minimal example:
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 the type-parameter overload:
handler.Use<NormalizeMiddleware>();At registration time, Plumber finds the InvokeAsync method by reflection, validates its shape, builds the instance via ActivatorUtilities.CreateInstance (passing next first), and compiles the dispatch into an expression tree. The per-invocation reflection cost is paid once, at registration. Subsequent invocations call the compiled lambda directly.
If the shape doesn't match, registration throws InvalidOperationException with a message naming the offending class.
Extra parameters declared on InvokeAsync after the required RequestContext parameter are resolved from the per-request scope — context.Services — on every invocation.
This is the safe place for DbContext, HttpClient, IUnitOfWork, and anything else with a per-request lifetime:
internal sealed class TokenizeMiddleware(RequestMiddleware<string, TextReport> next)
{
public Task InvokeAsync(
RequestContext<string, TextReport> context, // first parameter must be the context
ITokenizer tokenizer) // resolved from context.Services per request
{
context.ThrowIfCanceled();
context.Data["tokens"] = tokenizer.Tokenize(context.Request);
return next(context);
}
}The compiled dispatch reads context.Services and calls GetRequiredService<T> for each parameter past the first — fresh resolution every call. Scoped services see their per-request instance; transients are constructed afresh each invocation.
Use method injection on InvokeAsync for any service you'd hesitate to capture in a singleton.
Constructor parameters after next are resolved from the root IServiceProvider, not from 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 itself is registered with the DI container.
This is appropriate when the dependency is genuinely a singleton: ILogger<T>, TimeProvider, an IOptions<T> instance bound from configuration, a thread-safe singleton service.
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);
}
}Use method injection on InvokeAsync for scoped or transient services. The constructor-resolved instance is captured once at registration time and shared across all requests. A DbContext (scoped), an HttpClient resolved through IHttpClientFactory.CreateClient(...) (transient), or any other request-scoped dependency captured in the constructor will give every request the same instance — leading to stale data, thread-safety violations, or ObjectDisposedException from a dependency disposed at the end of an unrelated request.
The rule of thumb: if you're not sure whether the dependency is registered as a singleton, declare it on InvokeAsync. Method injection is always safe; constructor injection is only safe when the dependency is genuinely shared.
Middleware classes can be generic. Closed-generic registration is just spelling out the type parameters:
handler.Use<ErrorBoundary<MyRequest, MyResponse>>();Generic middleware is the right shape for cross-cutting infrastructure that isn't tied to a specific (TRequest, TResponse) pair — error boundaries, retry wrappers, generic logging, generic metrics. The class declares its own type parameters and Plumber treats the closed-over type the same as any other class.
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;
}
}
}Open-generic registration (registering ErrorBoundary<,> for arbitrary (TReq, TRes) pairs) isn't a Plumber concept — each handler is already typed to a single (TRequest, TResponse) pair, so you only ever register the closed form for the pair the handler was built with.
The class-middleware Use overload accepts params object[] for extra constructor arguments:
RequestHandler<TReq, TRes> Use<TMiddleware>(params object[] parameters)
where TMiddleware : class;Declare the constructor with next first, your extra parameters second, then any DI-resolved dependencies last:
internal sealed class RetryMiddleware(
RequestMiddleware<TReq, TRes> next,
int maxAttempts,
TimeSpan backoff,
ILogger<RetryMiddleware> logger)
{
public async Task InvokeAsync(RequestContext<TReq, TRes> context)
{
for (var attempt = 1; attempt <= maxAttempts; ++attempt)
{
try
{
await next(context);
return;
}
catch (Exception ex) when (attempt < maxAttempts)
{
logger.LogWarning(ex, "attempt {Attempt} failed; retrying", attempt);
await Task.Delay(backoff, context.CancellationToken);
}
}
}
}Register with the extra arguments after the type parameter:
handler.Use<RetryMiddleware>(3, TimeSpan.FromMilliseconds(200));Internally, Plumber prepends next to the parameter array and hands it to ActivatorUtilities.CreateInstance. ActivatorUtilities matches the supplied arguments by type before satisfying the rest from the root provider — so the order of the extra parameters in the constructor must match the order of the values in the Use<> call.
The same caveat as plain constructor injection applies: any DI-resolved parameter beyond next and your extras comes from the root provider and is captured once. Method injection on InvokeAsync is still the right place for scoped or transient services.
Use calls register components in order. The first component registered wraps everything else and runs first on the way in (and last on the way out). The order is exactly what you'd expect from reading the registrations top to bottom.
The pipeline itself is built lazily — composition happens on the first InvokeAsync call, not at Build() time. That's when the registered components are wrapped together right-to-left into a single delegate, and that's when the order freezes. After that point, calling Use throws:
// pseudocode — illustrates the failure mode
handler.Use<First>();
handler.Use<Second>();
await handler.InvokeAsync(request); // pipeline built, order frozen
handler.Use<Late>(); // throws InvalidOperationExceptionConfigure all your middleware before the first invocation. The Configure method in the CreateBuilder/Configure pattern — a convention that puts builder setup and Use calls in two named methods — gives you a single place to add every Use call, called once before any traffic flows through the handler.
The lazy-build also means that Use works on host-mode handlers built via RequestHandler.Create(serviceProvider) — see Advanced for the host-integration entry point. The same ordering rules apply.
-
Building a pipeline — builder configuration,
Build(), multiple builds -
Request lifecycle —
RequestContext, short-circuiting,Unit, timeouts, error handling -
Testing — testing middleware in isolation and through
PlumberApplicationFactory<TReq, TRes> - Concepts — the onion model and vocabulary
Documents Plumber v3.x · Repository · MIT License · Report an issue
Getting Started
Reference
- Building a pipeline
- Middleware
- Request lifecycle
- Testing
- PlumberApplicationFactory
- Advanced
- FAQ
- Migration
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