var app = builder.Build();
app.Use(async (context, next) =>
{
Console.WriteLine($"Incoming: {context.Request.Path}");
await next(); // pass control down the chain
Console.WriteLine($"Outgoing: {context.Response.StatusCode}");
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
The explicit await next() is the clearest illustration of the whole model — everything after that line runs after the rest of the pipeline (and the route handler) has already finished, on the way back up. next here is a RequestDelegate — a reference to "the rest of the pipeline as a single callable function" — which is exactly what makes composing an arbitrary number of middleware layers work: each one only needs to know about "the next thing," never the full chain.
Use vs. Run vs. Mapapp.Use(async (context, next) => { /* ... */ await next(); }); // can call next() — a real link in the chain
app.Run(async context => { await context.Response.WriteAsync("Hello"); }); // terminal — no next() available at all
app.Map("/admin", adminApp => { // branches the pipeline for a specific path prefix
adminApp.UseAuthorization();
adminApp.Run(async context => await context.Response.WriteAsync("Admin area"));
});
app.Use adds a middleware that can call next() to continue the chain; app.Run adds a terminal middleware with no next parameter at all, signaling "the pipeline ends here." app.Map branches the pipeline entirely for requests under a given path prefix, building what's effectively a separate, shorter pipeline for that branch.
For anything beyond a short inline lambda, ASP.NET Core middleware is conventionally written as a class with an InvokeAsync method, registered via app.UseMiddleware<T>():
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
public RequestTimingMiddleware(RequestDelegate next) // constructor runs ONCE, at startup
{
_next = next;
}
public async Task InvokeAsync(HttpContext context) // runs on EVERY request
{
var sw = Stopwatch.StartNew();
await _next(context);
context.Response.Headers["X-Response-Time-Ms"] = sw.ElapsedMilliseconds.ToString();
}
}
app.UseMiddleware<RequestTimingMiddleware>();
The constructor executes exactly once, when the app starts and the pipeline is built — this is where one-time setup (like capturing _next) belongs; InvokeAsync is what actually runs per-request. This same "constructed once, invoked per request" shape reappears, structurally identical, in Django's middleware classes below.