Custom Middleware — Writing Reusable Request Processing Components

Custom middleware lets you add reusable request/response processing to the entire pipeline. The canonical use cases are: adding security headers to every response, correlating requests with IDs, measuring and logging request duration, enforcing rate limits, and transforming request or response content. Middleware runs for every request to the application (unlike action filters which run only for controller actions), making it the right place for cross-cutting concerns that apply globally.

Convention-Based Middleware

// ── Convention-based middleware — InvokeAsync method, DI via constructor ──
public class SecurityHeadersMiddleware
{
    private readonly RequestDelegate _next;

    // Singleton-lifetime constructor (called once)
    public SecurityHeadersMiddleware(RequestDelegate next)
        => _next = next;

    // InvokeAsync — called per request; use DI parameters for scoped services
    public async Task InvokeAsync(HttpContext context)
    {
        // Add security headers to every response
        context.Response.Headers["X-Content-Type-Options"]    = "nosniff";
        context.Response.Headers["X-Frame-Options"]           = "DENY";
        context.Response.Headers["X-XSS-Protection"]          = "1; mode=block";
        context.Response.Headers["Referrer-Policy"]           = "strict-origin-when-cross-origin";
        context.Response.Headers["Permissions-Policy"]        = "camera=(), microphone=(), geolocation=()";

        if (!context.Request.IsHttps)
        {
            context.Response.Headers["Content-Security-Policy"] =
                "default-src 'self'; upgrade-insecure-requests";
        }

        await _next(context);
    }
}

// ── Register in Program.cs ────────────────────────────────────────────────
app.UseMiddleware<SecurityHeadersMiddleware>();
Note: In the convention-based pattern, the middleware class is instantiated as a singleton — the constructor is called once at startup. Per-request services (like ILogger<T> which is actually singleton-safe, or IPostRepository which is scoped) should be injected as InvokeAsync parameters, not constructor parameters. ASP.NET Core resolves InvokeAsync parameters from the request’s DI scope on each call. Injecting a Scoped service in the constructor would capture it at startup (singleton lifetime), creating the captive dependency bug.
Tip: For middleware that needs per-request scoped services, use the IMiddleware interface pattern instead of the convention-based pattern. IMiddleware is registered as a transient or scoped service in DI, so it is created fresh per request: public class MyMiddleware : IMiddleware { public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { ... } } then builder.Services.AddScoped<MyMiddleware>() and app.UseMiddleware<MyMiddleware>(). This eliminates the captive dependency concern entirely.
Warning: Middleware runs for every request — even for static files, health checks, and OPTIONS preflight requests. Keep middleware lightweight. Expensive operations (database lookups, HTTP calls) in middleware multiply across every request, including those that do not need them. Use endpoint routing filters or path checks (if (context.Request.Path.StartsWithSegments("/api"))) to skip middleware for paths that do not need it.

IMiddleware Interface Pattern

// ── IMiddleware — scoped/transient, can inject scoped services ─────────────
public class RequestTimingMiddleware : IMiddleware
{
    private readonly ILogger<RequestTimingMiddleware> _logger;

    // Constructor injection works normally — IMiddleware is created per request
    public RequestTimingMiddleware(ILogger<RequestTimingMiddleware> logger)
        => _logger = logger;

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var sw = Stopwatch.StartNew();
        try
        {
            await next(context);
        }
        finally
        {
            sw.Stop();
            _logger.LogInformation(
                "{Method} {Path} completed in {ElapsedMs}ms → {StatusCode}",
                context.Request.Method,
                context.Request.Path,
                sw.ElapsedMilliseconds,
                context.Response.StatusCode);
        }
    }
}

// ── Register as scoped in DI, then add to pipeline ────────────────────────
builder.Services.AddScoped<RequestTimingMiddleware>();
// ...
app.UseMiddleware<RequestTimingMiddleware>();

Common Mistakes

Mistake 1 — Injecting scoped services in convention-based middleware constructor

❌ Wrong — scoped service captured as singleton; state leaks between requests:

public class MyMiddleware(RequestDelegate next, IPostRepository repo) { }
// repo is Scoped — captured in singleton middleware constructor!

✅ Correct — inject scoped services as InvokeAsync parameters or use IMiddleware.

Mistake 2 — Running expensive operations in middleware for all requests

❌ Wrong — database lookup for every request including health checks and static files.

✅ Correct — check the request path first and skip middleware for irrelevant routes.

🧠 Test Yourself

You need to inject IPostRepository (Scoped) into middleware. What is the correct approach?