CodeOath
← All posts
Architecture & Patterns55 min total · 13 parts

Middleware Pipelines Compared: ASP.NET Core, Express.js, and Django

Contents — Part 3 of 13: ASP.NET Core
Part 3 of 13 · ~2 min

ASP.NET Core

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. Map

app.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.

Middleware as a class

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.