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();
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.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.