~/handys11
Browse the docs

dotnet/Ecosystem & Infrastructure

The middleware pipeline

intermediate

TL;DR

var app = builder.Build();

// Each middleware wraps everything registered after it.
app.Use(async (ctx, next) =>
{
    var sw = Stopwatch.StartNew();
    await next(ctx); // ... the rest of the pipeline runs here ...
    app.Logger.LogInformation(
        "{Path} → {Status} in {Ms} ms",
        ctx.Request.Path, ctx.Response.StatusCode, sw.ElapsedMilliseconds);
});

app.UseExceptionHandler("/error"); // outermost of the "real" pipeline
app.UseStaticFiles();              // short-circuits for files on disk
app.UseRouting();                  // matches the request to an endpoint
app.UseAuthentication();           // who are you?
app.UseAuthorization();            // are you allowed?
app.MapControllers();              // terminal: run the endpoint

// Class-based middleware: constructor deps are effectively singleton,
// scoped services arrive as InvokeAsync parameters.
public sealed class CorrelationIdMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext ctx, AppDbContext db)
    {
        ctx.Response.Headers["X-Correlation-Id"] = ctx.TraceIdentifier;
        await next(ctx);
    }
}

How it works

The pipeline is one composed RequestDelegate

Every Use, Run, and Map* call appends a delegate to an internal list. Build() folds that list from last to first, each one wrapping the next — so the first middleware registered ends up as the outermost layer. That’s the Russian doll: opening the outside layer means calling into the one nested inside it. Code before await next(ctx) runs on the way in; code after it runs on the way out, once every inner layer has finished — the logging middleware in the TL;DR starts its stopwatch before the rest of the pipeline runs and logs after it returns.

Short-circuiting

Not calling next at all is called short-circuiting — the request never reaches anything registered afterward. UseStaticFiles does this whenever it finds a matching file on disk; an authentication challenge does it by writing a 401/302 directly. Once the response has started streaming, touching Response.Headers or Response.StatusCode throws — check Response.HasStarted if a middleware needs to know.

Routing decides the endpoint; authorization decides on it

UseRouting matches the request to an endpoint and stores it on HttpContextGetEndpoint() is null before UseRouting runs and non-null after, as long as a match was found. The endpoint itself doesn’t execute until dispatch, which is what UseEndpoints represents (explicitly or not). Any middleware placed between routing and dispatch can inspect the selected endpoint’s metadata (route data, [Authorize] attributes, custom attributes) before it runs — which is exactly why UseAuthorization has to sit in that gap: it needs to see which endpoint was selected to know what policy to enforce, and it has to run before dispatch to actually block it. In the minimal hosting model you rarely call UseRouting/UseEndpoints yourself: ASP.NET Core inserts routing near the start of the pipeline and appends endpoint dispatch at the very end, and it’ll even auto-insert UseAuthentication/UseAuthorization right after routing if you registered the services but never called them explicitly. That auto-wiring is a safety net, not a substitute for controlling the order yourself.

Map branches, UseWhen branches and rejoins

Map/MapWhen split the pipeline by path or predicate — the branch replaces the rest of the main pipeline for matching requests; it doesn’t run in addition to it. UseWhen branches on a predicate too, but rejoins the main pipeline afterward if the branch doesn’t short-circuit, which makes it the right tool for “add this for a subset of requests without losing the rest of the pipeline.”

Middleware instances live for the app, not the request

UseMiddleware<T>() constructs CorrelationIdMiddleware exactly once, at startup, using only what’s available from the constructor (the next delegate and anything registered in DI — resolving a scoped or transient service there makes it captive, pinned to the app’s lifetime instead of the request’s). Per-request, scoped dependencies — AppDbContext above — are instead resolved fresh on every call and passed as extra InvokeAsync parameters, because the framework invokes InvokeAsync through the current request’s DI scope.

Gotchas

  • Order bugs are the classic failure mode. In an explicitly-ordered pipeline (the common case once you call UseRouting/UseEndpoints yourself, e.g. in an MVC or Razor Pages app), placing UseAuthorization after dispatch means it never runs in time to block anything; an exception handler registered late only catches exceptions thrown by middleware after it, missing everything above.
  • “The response has already started.” Setting headers or the status code after the body has begun streaming throws InvalidOperationException — check Response.HasStarted before touching either from code that runs late in the pipeline (e.g. after await next(ctx)).
  • Forgetting await next(ctx) silently short-circuits the request — nothing downstream ever runs, and there’s no exception to point at the mistake.
  • Capturing a scoped service in a middleware constructor is the captive dependency bug again: the instance is created once at startup and reused for every request forever. Take it as an InvokeAsync parameter instead.
  • Map("/admin", ...) rebuilds a sub-pipeline from scratch. It doesn’t inherit the middleware registered after it in the main pipeline — anything shared (auth, logging) has to be re-registered inside the branch or run before the Map call.

Further reading