Writing Middleware
Write inline, convention-based and IMiddleware components, inject dependencies with the right lifetime and change the response safely before it starts.
public sealed class TimingMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, IScopedThing scoped) { await next(context); }
}
app.UseMiddleware<TimingMiddleware>();There are three ways to write middleware. All of them end up as the same RequestDelegate chain; they differ in how they are constructed and how they get dependencies.
The three shapes
| Shape | Created | Dependencies | Use for |
|---|---|---|---|
Inline app.Use(...) | Once | Captured or resolved from context.RequestServices | A few lines, one app |
| Convention-based class | Once, at start-up, from the root provider | Singletons in the constructor; scoped services as InvokeAsync parameters | The default for reusable middleware; fastest |
IMiddleware | Per request, from the request scope via IMiddlewareFactory | Any lifetime in the constructor | Middleware that needs scoped services in many places |
A convention-based class has a public constructor whose first parameter is RequestDelegate next, and a public Invoke or InvokeAsync method that takes HttpContext first and returns Task. Extra parameters on InvokeAsync are resolved from the request scope on every call. IMiddleware must also be registered in DI (AddScoped<T>() or AddTransient<T>()); forgetting that throws on the first request, not at start-up.
Before and after next()
Code before await next(context) sees the request on the way in; code after sees the response on the way out. By the time next returns, the endpoint has usually written the body and the response headers are already on the wire: Response.HasStarted is true, and setting a header throws InvalidOperationException: Headers are read-only, response has already started. To add a header that depends on the whole request, register a callback with context.Response.OnStarting(...); it runs just before the headers are sent. Wrap next in try/finally when the after-step must run even on exceptions, such as recording a duration.
The captive dependency trap
A convention-based class is built once from the root container. Put a scoped service (a DbContext, a tenant context) in its constructor and in Development the scope validation throws at start-up. In Production that validation is off, so the first instance is captured and shared by every request on every thread: tenant data leaks between customers and a DbContext gets used concurrently. Inject scoped services into InvokeAsync, or switch to IMiddleware.
What a team decides
- Use convention-based classes by default and expose them through an extension method such as
UseCorrelationId(), so the call site reads like built-in middleware. - Do not write middleware for something an endpoint filter can do. Filters see bound arguments and endpoint metadata; middleware sees raw HTTP and runs for every request, including 404s and static files.
- Keep middleware allocation-free on the hot path: it runs on every request.
- Never swallow exceptions in custom middleware; let the exception handler produce one consistent ProblemDetails response.
Example
When to use it
- Accept an X-Correlation-Id from the gateway, generate one when missing and echo it on every response.
- Add a Server-Timing header with the app's processing time using Response.OnStarting.
- Fix a tenant data leak caused by a scoped TenantContext injected into a convention-based middleware constructor.
More examples
Pitfall: a scoped service in the constructor
Convention-based middleware is a singleton in practice. In Development <code>ValidateScopes</code> stops start-up with <code>Cannot resolve scoped service 'TenantContext' from root provider</code>, but Production has that check off, so the bug ships and shows up as data from one tenant appearing in another tenant's response.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<TenantContext>();
var app = builder.Build();
app.UseMiddleware<TenantMiddleware>();
app.MapGet("/invoices", (TenantContext tenant) => TypedResults.Ok(new { tenant.TenantId }));
app.Run();
public sealed class TenantContext
{
public string TenantId { get; set; } = "";
}
// Wrong: TenantMiddlewareBroken(RequestDelegate next, TenantContext tenant)
// is built once from the root provider. In Production every request shares one
// TenantContext, so tenant "acme" can read tenant "globex" invoices under load.
// Right: the scoped dependency is a parameter of InvokeAsync, resolved per request.
// The tenant comes from the authenticated user's claim (after UseAuthentication),
// never from a header the caller can set to any value.
public sealed class TenantMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, TenantContext tenant)
{
tenant.TenantId = context.User.FindFirst("tenant_id")?.Value ?? "";
if (tenant.TenantId.Length == 0)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
await next(context);
}
}Packaging middleware behind an extension method
Arguments passed to <code>UseMiddleware<T>(args)</code> fill constructor parameters that DI cannot, here the threshold. <code>ILogger</code> is a singleton the host registers. <code>TimeProvider</code> is not registered by default, so the app registers <code>TimeProvider.System</code> as a singleton: that makes it safe in the constructor and lets a test swap in <code>FakeTimeProvider</code>. The <code>finally</code> block records slow requests even when the endpoint throws; the exception still propagates to the exception handler.
var builder = WebApplication.CreateSlimBuilder(args);
// The host does not register TimeProvider; without this line start-up fails with
// "Unable to resolve service for type 'System.TimeProvider'".
builder.Services.AddSingleton(TimeProvider.System);
var app = builder.Build();
app.UseRequestDuration(slowThreshold: TimeSpan.FromMilliseconds(500));
app.MapGet("/products", () => TypedResults.Ok(new[] { "KET-0042", "MUG-0007" }));
app.Run();
public static class RequestDurationExtensions
{
public static IApplicationBuilder UseRequestDuration(this IApplicationBuilder app, TimeSpan slowThreshold) =>
app.UseMiddleware<RequestDurationMiddleware>(slowThreshold);
}
public sealed class RequestDurationMiddleware(
RequestDelegate next,
ILogger<RequestDurationMiddleware> logger,
TimeProvider clock,
TimeSpan slowThreshold)
{
public async Task InvokeAsync(HttpContext context)
{
var started = clock.GetTimestamp();
try
{
await next(context);
}
finally
{
var elapsed = clock.GetElapsedTime(started);
if (elapsed > slowThreshold)
{
logger.LogWarning("Slow request {Method} {Path} took {ElapsedMs} ms with status {StatusCode}",
context.Request.Method, context.Request.Path, elapsed.TotalMilliseconds, context.Response.StatusCode);
}
}
}
}Unit testing middleware with DefaultHttpContext
Middleware is a class with a delegate and a method, so it can be tested without a server. <code>DefaultHttpContext</code> gives a real request and response object, and a lambda stands in for <code>next</code>, which lets the test assert that short-circuiting really skipped the rest of the pipeline.
using Xunit;
public sealed class ApiKeyMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
if (!context.Request.Headers.ContainsKey("X-Api-Key"))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
await next(context);
}
}
public class ApiKeyMiddlewareTests
{
[Fact]
public async Task Missing_key_returns_401_and_does_not_call_next()
{
var nextCalled = false;
var middleware = new ApiKeyMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
var context = new DefaultHttpContext();
context.Request.Path = "/orders";
await middleware.InvokeAsync(context);
Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode);
Assert.False(nextCalled);
}
[Fact]
public async Task Present_key_calls_next()
{
var nextCalled = false;
var middleware = new ApiKeyMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
var context = new DefaultHttpContext();
context.Request.Headers["X-Api-Key"] = "test-key";
await middleware.InvokeAsync(context);
Assert.True(nextCalled);
}
}
Discussion