The Web API Middleware Pipeline — Request Context and Short-Circuiting

Every HTTP request to an ASP.NET Core Web API flows through a pipeline of middleware components before reaching a controller, and flows back through the same components in reverse after the response is generated. Each middleware can inspect the request, modify the response, short-circuit the pipeline (return early without calling the next component), or simply pass the request along. For Web APIs, key middleware concerns are correlation IDs (for tracing requests across services), timing (for performance monitoring), and standard headers (for API consumers). Understanding when middleware is the right tool versus action filters is the first architectural decision in any cross-cutting concern.

Custom Middleware Examples

// ── Correlation ID Middleware ──────────────────────────────────────────────
public class CorrelationIdMiddleware(RequestDelegate next)
{
    private const string HeaderName = "X-Correlation-Id";

    public async Task InvokeAsync(HttpContext context)
    {
        // Use incoming correlation ID (from upstream service) or generate one
        var correlationId = context.Request.Headers[HeaderName].FirstOrDefault()
            ?? Guid.NewGuid().ToString("N")[..12];

        // Store for use in logging scopes and downstream services
        context.Items["CorrelationId"] = correlationId;
        context.Response.Headers[HeaderName] = correlationId;

        // Add to the current log scope so ALL logs in this request include it
        using (Serilog.Context.LogContext.PushProperty("CorrelationId", correlationId))
        {
            await next(context);
        }
    }
}

// ── Request Timing Middleware ─────────────────────────────────────────────
public class RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
    private static readonly double SlowRequestThresholdMs = 500;

    public async Task InvokeAsync(HttpContext context)
    {
        var sw = Stopwatch.StartNew();
        await next(context);
        sw.Stop();

        var elapsed = sw.ElapsedMilliseconds;
        context.Response.Headers["X-Response-Time-Ms"] = elapsed.ToString();

        if (elapsed > SlowRequestThresholdMs)
            logger.LogWarning(
                "Slow request: {Method} {Path} took {ElapsedMs}ms (threshold: {ThresholdMs}ms)",
                context.Request.Method, context.Request.Path,
                elapsed, SlowRequestThresholdMs);
    }
}

// ── Register in Program.cs (ORDER MATTERS) ────────────────────────────────
app.UseMiddleware<CorrelationIdMiddleware>();   // first — sets correlation ID for all logs
app.UseMiddleware<RequestTimingMiddleware>();   // before controller pipeline
app.UseHttpsRedirection();
app.UseCors("AllowAngular");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Note: Middleware runs for every request — including health checks, static files, and unmatched routes. Action filters only run for controller actions. When a cross-cutting concern should apply to all requests (correlation IDs, request timing, security headers), middleware is the correct layer. When it should only apply to specific controllers or actions (audit logging, idempotency), action filters are more appropriate. Using middleware for controller-only concerns wastes cycles on every non-controller request.
Tip: Inject the middleware’s dependencies through the InvokeAsync method parameter (not the constructor) for Scoped and Transient services. Middleware instances are Singleton by default (created once at startup), so constructor injection only works for Singleton services. Scoped services (like DbContext) must be injected via the InvokeAsync(HttpContext context, IScopedService service) signature — ASP.NET Core resolves them from the current request’s scope per invocation.
Warning: Never set response headers after await next(context) returns if the response has already started writing to the wire. Check context.Response.HasStarted before setting headers post-pipeline. Once the response body starts streaming to the client, headers cannot be modified — any attempt throws an InvalidOperationException. For headers you want on every response, set them before calling await next(context) (or in OnStarting callback for headers that depend on response content).

app.Use vs app.Run vs app.Map

// ── app.Use — passes to next middleware ──────────────────────────────────
app.Use(async (context, next) =>
{
    // Before next middleware
    await next(context);
    // After next middleware (response available)
});

// ── app.Run — terminal (does not call next) ────────────────────────────────
app.Run(async context => await context.Response.WriteAsync("Terminal response"));
// Any middleware after app.Run is NEVER called — only use at the end

// ── app.Map — branch pipeline for specific path ───────────────────────────
app.Map("/api/debug", debugApp =>
{
    // Only runs for requests starting with /api/debug
    debugApp.Run(async ctx =>
        await ctx.Response.WriteAsJsonAsync(new { env = "development" }));
});
// All other requests continue past this map point

Common Mistakes

Mistake 1 — Setting response headers after response has started (InvalidOperationException)

❌ Wrong — setting headers after controller wrote response body:

await next(context);
context.Response.Headers["X-Custom"] = "value";  // may throw if response started!

✅ Correct — set headers before await next(), or use context.Response.OnStarting() callback.

Mistake 2 — Injecting Scoped services in middleware constructor (captive dependency)

❌ Wrong — Scoped DbContext in constructor; single instance shared across all requests.

✅ Correct — inject Scoped services via InvokeAsync(HttpContext context, IMyService svc) parameter.

🧠 Test Yourself

Correlation ID middleware generates a new GUID for each request and stores it in context.Items["CorrelationId"]. A controller needs to include this ID in a response DTO. How does the controller access it?