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

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

Part 3 of 13 · ~4 min

Prototype One: ASP.NET Core

var app = builder.Build();

app.Use(async (context, next) =>
{
    Console.WriteLine($"{context.Request.Method} {context.Request.Path}");
    await next(); // hand off to whatever comes next
});

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

Trace that method call and the whole model becomes concrete: the two Console.WriteLine lines wrapping await next() aren't equivalent — the first runs on the way in, the second doesn't fire until everything downstream, every later middleware plus approveReturn itself, has already run to completion and is unwinding back out. next is typed as a RequestDelegate, one object standing in for the entire remainder of the chain. That's what keeps each middleware simple regardless of how long the chain eventually gets: it holds a reference to exactly one thing, its immediate successor, and never has to reason about anything beyond that.

Use, Run, and Map — Three Different Promises

app.Use(async (context, next) => { /* ... */ await next(); });  // a real link — can call next()

app.Run(async context => { await context.Response.WriteAsync("Fernwood Outdoor — Counter API"); });  // terminal — no next parameter exists at all

app.Map("/admin", adminApp =>  // branches the pipeline off entirely for one path prefix
{
    adminApp.UseAuthorization();
    adminApp.Run(async context =>
    {
        var flagged = await LoadFlaggedReturnsAsync();
        await context.Response.WriteAsJsonAsync(flagged);
    });
});

Counter uses all three. app.Use is what most of the pipeline is made of — logging, auth, authorization — because each of those needs to call next() and keep going. app.Run is deliberately a dead end: no next parameter is even offered, so there's nothing to accidentally forget to call. And app.Map("/admin", ...) is how the regional dashboard — the page Dana actually used to catch Marcus's return — gets its own separate, shorter pipeline, branched off entirely for anything under /admin, with its own authorization rule and its own terminal handler.

Middleware as a Class, and the DI Trap Hiding In It

A one-line lambda is fine for logging; Counter's team wanted approval attempts audited with real timing data, which meant a proper class:

public class ApprovalAuditMiddleware
{
    private readonly RequestDelegate _next;

    public ApprovalAuditMiddleware(RequestDelegate next)  // constructor runs ONCE, when the app starts
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)  // this method runs on EVERY request
    {
        var stopwatch = Stopwatch.StartNew();
        await _next(context);

        if (context.Request.Path.StartsWithSegments("/returns") && context.Request.Method == "POST")
            Console.WriteLine($"approval attempt: {stopwatch.ElapsedMilliseconds}ms, status {context.Response.StatusCode}");
    }
}

app.UseMiddleware<ApprovalAuditMiddleware>();

Hold onto "the constructor runs once, InvokeAsync runs per request" — it's about to matter more than it looks like it should. A few weeks into the bake-off, someone wanted ApprovalAuditMiddleware to write those timing rows straight into the database instead of the console, and reached for the obvious move:

public class ApprovalAuditMiddleware
{
    private readonly RequestDelegate _next;
    private readonly FernwoodDbContext _db;  // looks completely reasonable

    public ApprovalAuditMiddleware(RequestDelegate next, FernwoodDbContext db)
    {
        _next = next;
        _db = db;  // this line is where it breaks
    }
    // ...
}

// System.InvalidOperationException: Cannot resolve scoped service
// 'FernwoodDbContext' from root provider.

The app wouldn't even start. Here's why: app.UseMiddleware<T>() builds this class exactly once, at startup, using the application's root service provider — which is effectively a singleton lifetime, regardless of anything you've registered T as. A DbContext is registered scoped — one instance per request — on purpose, because handing two concurrent requests the same DbContext instance is its own well-known source of corruption. Ask the root provider, which has no concept of "this particular request," to hand over a scoped service, and it refuses outright rather than silently handing back something that would misbehave under concurrent load.

The fix isn't a different container lifetime — it's moving the dependency to a place ASP.NET Core actually resolves it per request:

public class ApprovalAuditMiddleware
{
    private readonly RequestDelegate _next;

    public ApprovalAuditMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context, FernwoodDbContext db)  // resolved fresh, per request
    {
        var stopwatch = Stopwatch.StartNew();
        await _next(context);

        if (context.Request.Path.StartsWithSegments("/returns") && context.Request.Method == "POST")
        {
            db.ApprovalAudits.Add(new ApprovalAudit
            {
                Path = context.Request.Path,
                StatusCode = context.Response.StatusCode,
                ElapsedMs = stopwatch.ElapsedMilliseconds,
            });
            await db.SaveChangesAsync();
        }
    }
}

InvokeAsync gets special treatment beyond its first HttpContext parameter: anything else listed there is resolved from the current request's own DI scope, not the app-wide root — so a scoped FernwoodDbContext shows up correctly, fresh, on every single call, with nothing about the class's lifetime needing to change at all.