BrandGhost
ASP.NET Core Middleware: Building and Using the Request Pipeline

ASP.NET Core Middleware: Building and Using the Request Pipeline

ASP.NET Core Middleware: Building and Using the Request Pipeline

ASP.NET Core middleware is the backbone of the request processing pipeline. Everything in asp.net core middleware -- authentication, routing, static files, response compression, rate limiting, error handling -- is implemented as middleware. If you want to add cross-cutting behavior to your application, you write middleware. Understanding how the pipeline works, how to write middleware correctly, and how to order it properly is foundational knowledge for anyone building serious ASP.NET Core applications.

This guide covers the complete picture for .NET 10: the request pipeline model, registration methods, the IMiddleware interface, ordering rules, and two complete real-world examples -- correlation ID propagation and request timing.

The Request Pipeline Concept

The asp.net core middleware pipeline is a sequence of components, each of which can examine and transform the HTTP request as it flows in, optionally call the next component in the chain, and then examine and transform the HTTP response as it flows back out. The classic mental model is Russian nesting dolls -- each middleware wraps the next, creating concentric layers around the innermost handler (your controller action or minimal API endpoint).

This bidirectional nature is what makes middleware powerful. A single middleware component has two opportunities to act: before calling next() (operating on the request, before any downstream middleware runs) and after calling next() (operating on the response, after all downstream middleware has run). This is how response compression works -- it lets the action produce the full response body, then compresses it on the way out. It is also how request timing works -- record a timestamp before calling next(), record another after, and calculate elapsed time.

Each middleware receives an HttpContext containing everything about the current request and response, and a RequestDelegate called next that invokes the next component in the chain. Calling next() continues the pipeline. Not calling it short-circuits -- the request stops here and the response starts flowing back immediately.

How Middleware is Registered

ASP.NET Core provides several methods on IApplicationBuilder (or WebApplication in .NET 10's minimal hosting model) to add middleware:

app.Use adds a middleware delegate that can call the next component. It is the most flexible option. app.Run adds a terminal middleware that does not call next -- it always short-circuits. app.Map branches the pipeline based on a path prefix. app.MapWhen branches based on a predicate. app.UseWhen conditionally adds middleware without branching (the main pipeline continues after the conditional branch).

Here is a concrete example using app.Use to add a simple inline middleware that adds a response header:

app.Use(async (context, next) =>
{
    // Before downstream middleware runs
    context.Response.Headers["X-Frame-Options"] = "DENY";
    context.Response.Headers["X-Content-Type-Options"] = "nosniff";

    await next(context); // Call the next middleware

    // After downstream middleware has run -- response is being written
    // You can examine context.Response here, but be careful:
    // headers may already be sent if the body has started writing
});

The app.Use overload in .NET 10 can take either Func<HttpContext, RequestDelegate, Task> or an additional Func<HttpContext, Func<Task>, Task> overload. Both work fine. For anything beyond a few lines, the class-based approach is far more maintainable.

Middleware Order Matters

This is the most critical thing to understand about asp.net core middleware. The order in which you register middleware is the order in which it executes on the way in, and the reverse order on the way out. Wrong middleware order is a common source of subtle bugs that are hard to diagnose.

A commonly preferred pipeline order for a production ASP.NET Core Web API in .NET 10 -- not every app needs all of these, and the right order depends on your application's requirements:

app.UseExceptionHandler();        // Must be first -- catches exceptions from everything below
app.UseHsts();                    // HTTP Strict Transport Security
app.UseHttpsRedirection();        // Redirect HTTP to HTTPS
app.UseStaticFiles();             // Serve wwwroot files before routing kicks in
app.UseRouting();                 // Matches routes -- must precede UseAuthentication/UseAuthorization
app.UseCors();                    // CORS headers -- must come after UseRouting
app.UseAuthentication();          // Populates HttpContext.User -- must precede UseAuthorization
app.UseAuthorization();           // Enforces authorization -- must follow UseAuthentication
app.UseRateLimiter();             // Rate limiting -- after auth so you can rate limit by user
app.MapControllers();             // Executes matched endpoints

The ordering has specific reasons behind it. UseExceptionHandler must be first so it wraps the entire pipeline and can catch any exception. Authentication must precede authorization because you cannot authorize an unauthenticated request -- you need to know who is asking before deciding what they are allowed to do. CORS must come after routing because it needs to know which endpoint is matched to apply the correct CORS policy. Static files must come before routing to avoid the routing overhead for file requests.

Getting the order wrong can cause security vulnerabilities. If UseAuthorization is registered before UseAuthentication, unauthorized users might access protected resources because HttpContext.User was never populated. If UseExceptionHandler is not first, exceptions thrown by UseHttpsRedirection or UseCors will not be caught and handled gracefully.

The IMiddleware Interface: The DI-Friendly Approach

Inline app.Use lambdas are fine for simple transforms, but they cannot be unit tested, cannot have constructor-injected dependencies, and become messy when logic grows beyond a few lines. The IMiddleware interface solves all three problems.

Implementing IMiddleware requires a single method: InvokeAsync(HttpContext context, RequestDelegate next). The class is registered in DI as a service, and ASP.NET Core resolves it from the container on each request. This means your middleware can have any lifetime -- Transient, Scoped, or Singleton -- depending on its dependencies.

Here is a complete request timing middleware that measures how long the downstream pipeline takes to process a request:

public sealed class RequestTimingMiddleware : IMiddleware
{
    private readonly ILogger<RequestTimingMiddleware> _logger;

    public RequestTimingMiddleware(ILogger<RequestTimingMiddleware> logger)
    {
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var stopwatch = Stopwatch.StartNew();

        try
        {
            await next(context);
        }
        finally
        {
            stopwatch.Stop();

            _logger.LogInformation(
                "HTTP {Method} {Path} responded {StatusCode} in {ElapsedMilliseconds}ms",
                context.Request.Method,
                context.Request.Path,
                context.Response.StatusCode,
                stopwatch.ElapsedMilliseconds);

            // Optionally expose timing as a response header for debugging
            if (!context.Response.HasStarted)
            {
                context.Response.Headers["X-Response-Time-Ms"] =
                    stopwatch.ElapsedMilliseconds.ToString();
            }
        }
    }
}

Register and use it in Program.cs:

// Register the middleware as a service
builder.Services.AddTransient<RequestTimingMiddleware>();

// Add it to the pipeline
app.UseMiddleware<RequestTimingMiddleware>();

The finally block is important. If downstream middleware throws an exception, the timing measurement should still complete and the log entry should still be written. Without finally, an unhandled exception would silently skip the log line and the X-Response-Time-Ms header.

Inline Middleware vs Class-Based Middleware

The choice between inline app.Use lambdas and class-based IMiddleware implementations is driven by a few factors.

Use inline middleware when the logic is trivial, self-contained, and does not need testing -- adding security response headers, setting a default content type, simple request filtering based on a static condition. The advantage is zero friction: write the lambda and move on.

Use class-based IMiddleware for anything that is testable, reusable, stateful, or has external dependencies. Request timing, correlation ID propagation, rate limiting, audit logging, feature flags -- all of these involve logic complex enough to warrant isolated unit tests and constructor-injected dependencies. The IMiddleware interface makes the testing story clean: construct the middleware with test doubles, call InvokeAsync with a test HttpContext, and assert outcomes without involving any HTTP infrastructure.

This tradeoff mirrors the choice between anonymous lambda handlers and named, DI-registered handlers in other areas of ASP.NET Core. The Logging in .NET complete guide shows the same principle for log enrichers.

Building a Correlation ID Middleware

Correlation IDs are one of the most practically valuable things you can add to a production API. Every request gets a unique identifier -- either provided by the client in an X-Correlation-Id header, or generated by the server if none is present. The ID is added to the response headers, added to the logging scope (so every log entry in the request includes it), and often propagated to downstream HTTP calls.

This is a perfect use case for custom asp.net core middleware:

public sealed class CorrelationIdMiddleware : IMiddleware
{
    private const string CorrelationIdHeader = "X-Correlation-Id";

    private readonly ILogger<CorrelationIdMiddleware> _logger;

    public CorrelationIdMiddleware(ILogger<CorrelationIdMiddleware> logger)
    {
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var correlationId = GetOrCreateCorrelationId(context);

        // Add to response so clients can correlate their logs with server logs
        context.Response.Headers[CorrelationIdHeader] = correlationId;

        // Add to the logging scope -- every log entry within this request gets the ID
        using var logScope = _logger.BeginScope(
            new Dictionary<string, object>
            {
                ["CorrelationId"] = correlationId
            });

        // Make the ID accessible to controllers and other middleware
        context.Items["CorrelationId"] = correlationId;

        await next(context);
    }

    private static string GetOrCreateCorrelationId(HttpContext context)
    {
        if (context.Request.Headers.TryGetValue(CorrelationIdHeader, out var correlationId)
            && !string.IsNullOrWhiteSpace(correlationId))
        {
            return correlationId.ToString();
        }

        return Guid.NewGuid().ToString("N");
    }
}

Register early in the pipeline -- before UseRouting so the correlation ID is available for all downstream logging:

builder.Services.AddTransient<CorrelationIdMiddleware>();

// Register early -- before routing, authentication, and controller execution
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseExceptionHandler();
app.UseRouting();
// ... rest of pipeline

For structured logging to pick up the correlation ID from the logging scope, ensure your logger provider supports scopes. Serilog does -- see the How to Set Up Serilog in ASP.NET Core guide for the configuration details.

Short-Circuiting the Pipeline

Not every middleware should call next. Sometimes you want to terminate the pipeline immediately and return a response directly. This is called short-circuiting. It is the pattern used by health check endpoints, rate limiters, cached response middleware, and IP filtering.

app.Use(async (context, next) =>
{
    // Simple IP allowlist example
    var remoteIp = context.Connection.RemoteIpAddress?.ToString();
    var allowedIps = new[] { "127.0.0.1", "::1" };

    if (context.Request.Path.StartsWithSegments("/admin") &&
        !allowedIps.Contains(remoteIp))
    {
        context.Response.StatusCode = StatusCodes.Status403Forbidden;
        await context.Response.WriteAsync("Access denied.");
        return; // Short-circuit -- do NOT call next
    }

    await next(context);
});

Short-circuiting is efficient. When middleware decides a request should not proceed, returning immediately without calling next means no downstream middleware, no routing, no controller execution -- just the direct response. Health check endpoints use this pattern: they inspect a path prefix, return the health status, and return without involving the routing or controller infrastructure.

The app.Run method is syntactic sugar for terminal middleware that always short-circuits. It never calls next because there is no next parameter:

app.Run(async context =>
{
    await context.Response.WriteAsync("This is the terminal middleware.");
});

Built-in Middleware Overview

ASP.NET Core ships with a large library of built-in middleware components. Knowing what exists prevents you from reinventing wheels.

UseStaticFiles serves files from wwwroot before routing runs. UseCors handles Cross-Origin Resource Sharing preflight requests and adds CORS headers. UseResponseCompression gzip/brotli compresses response bodies. UseRateLimiter (introduced in .NET 7, refined in .NET 10) applies rate limiting policies defined with AddRateLimiter. UseOutputCache (introduced in .NET 7) caches entire endpoint responses. UseRequestLocalization sets culture info from Accept-Language headers.

Each of these follows the same asp.net core middleware pattern -- they are wrappers around IMiddleware or delegates, registered in the DI container, and added to the pipeline with an extension method on IApplicationBuilder. Understanding this pattern means understanding all of them.

Middleware and Design Patterns

ASP.NET Core middleware is not just a framework concept -- it is a concrete implementation of the Chain of Responsibility pattern. Each middleware is a handler in the chain. It can handle the request itself (short-circuit) or pass it to the next handler. The pipeline is built at application startup, and the chain is fixed for the lifetime of the application.

This structural similarity has practical implications. The Decorator pattern also appears in middleware -- each component wraps the next, adding behavior without modifying inner components. Request timing, correlation IDs, error handling -- each wraps the pipeline and adds cross-cutting concerns transparently.

Understanding these patterns helps when you need to decide where behavior belongs. If the concern is request/response lifecycle management -- timing, correlation, compression, caching -- it belongs in middleware. If the concern is domain logic or business rules -- validation, authorization based on business state, domain exception handling -- it belongs closer to the application layer. The Facade pattern is another useful lens here: middleware is often the facade that hides complex cross-cutting subsystems behind a simple request/response interface.

Frequently Asked Questions

What is the difference between app.Use, app.Run, and app.Map?

app.Use adds middleware that can call the next component in the pipeline. It is the standard choice for middleware that needs to inspect both request and response or add behavior without terminating the pipeline. The pipeline continues past it when next is called.

app.Run adds terminal middleware that never calls next. Any middleware registered after app.Run in Program.cs is unreachable and effectively dead code. Use app.Run only as the final fallback in the pipeline.

app.Map creates a branch in the pipeline based on a path prefix. Requests matching the prefix enter the branch; other requests continue down the main pipeline. Branching is useful for serving different content types or applying separate middleware stacks to different path segments -- for example, serving a health check endpoint with no authentication or logging overhead.

Why does middleware order matter so much?

Middleware order matters because the pipeline is a sequence, not a set. Each component wraps the next. If authentication is registered after authorization, the HttpContext.User principal is not populated when authorization runs, so every request appears unauthenticated and may be allowed through or rejected incorrectly depending on your policy.

Similarly, if UseExceptionHandler is not registered first, exceptions thrown by other middleware (including UseHttpsRedirection and UseRouting) will not be caught. The rule of thumb is: put protective middleware early (exception handling, security headers, HTTPS redirection) and action middleware late (routing, authentication, authorization, endpoint execution). Consult the Microsoft documentation for the canonical order when building new applications.

How do I unit test custom IMiddleware implementations?

Construct the middleware with mock or stub dependencies, create a DefaultHttpContext, configure context.Request with the test scenario, and call InvokeAsync. Use a captured RequestDelegate to verify whether next was called (pipeline continued) or not (short-circuited).

var middleware = new CorrelationIdMiddleware(NullLogger<CorrelationIdMiddleware>.Instance);
var context = new DefaultHttpContext();
context.Request.Headers["X-Correlation-Id"] = "test-123";

var nextCalled = false;
Task Next(HttpContext ctx) { nextCalled = true; return Task.CompletedTask; }

await middleware.InvokeAsync(context, Next);

Assert.True(nextCalled);
Assert.Equal("test-123", context.Response.Headers["X-Correlation-Id"].ToString());

This pattern requires no HTTP server, no WebApplicationFactory, and no network. Tests run in milliseconds and exercise the middleware logic in complete isolation.

What is the difference between IMiddleware and convention-based middleware?

ASP.NET Core supports two middleware patterns. Convention-based middleware is a class with a constructor that accepts RequestDelegate and an Invoke or InvokeAsync method. Conventional middleware classes can receive dependencies via constructor injection and through InvokeAsync parameters. The key difference from IMiddleware is the activation and lifetime model -- IMiddleware instances are resolved per request from DI, giving them scoped lifetime support, whereas conventional middleware is instantiated once at startup (making it effectively singleton-scoped for its constructor dependencies).

Conventional middleware is instantiated once at startup, so its constructor-injected dependencies are effectively singleton-scoped. Use InvokeAsync parameter injection for scoped or transient dependencies. IMiddleware instances are resolved per request, so Transient and Scoped lifetimes work correctly. IMiddleware represents a different activation model with different tradeoffs -- it is not strictly "better", just suited to different situations. Choose IMiddleware when you need scoped DI dependencies in your middleware; use conventional middleware for simpler cases or when you control the lifetime carefully. For stateless, dependency-free middleware, either works fine.

How do I access middleware from a controller or service?

You cannot call middleware directly from a controller -- that is not how the pipeline works. Middleware runs at the pipeline level, before and after controllers. If you need data that middleware computed (like a correlation ID or the authenticated user's tenant ID), store it in HttpContext.Items inside the middleware and read it in the controller via IHttpContextAccessor.

// In middleware:
context.Items["CorrelationId"] = correlationId;

// In a controller:
public MyController(IHttpContextAccessor httpContextAccessor)
{
    _correlationId = httpContextAccessor.HttpContext?.Items["CorrelationId"]?.ToString();
}

Register IHttpContextAccessor with builder.Services.AddHttpContextAccessor(). This gives any service in the DI container access to the current request context, including anything stored in Items by middleware.

Should I write middleware or use an action filter for cross-cutting concerns?

Use asp.net core middleware when the concern applies to the entire pipeline -- including non-MVC requests like static files, health checks, and minimal API endpoints. Request timing, correlation IDs, HTTPS redirection, and response compression are all legitimate middleware concerns because they apply regardless of what handles the request.

Use action filters when the concern is specific to MVC controllers and actions. Model state validation customization, response envelope wrapping for specific controllers, and exception handling that depends on action metadata are filter concerns. Filters run after routing and model binding, so they have access to action descriptors and model state. Middleware does not.

The heuristic: if the behavior should run for all requests (including static files and health checks), use middleware. If it should only run for MVC actions and needs MVC context, use filters. If you are unsure, middleware is usually the safer choice because it provides broader coverage.

How does asp.net core middleware interact with dependency injection lifetimes?

With convention-based middleware, the middleware class itself is instantiated once at startup, so its constructor-injected dependencies are effectively singleton-scoped. Use InvokeAsync parameter injection for scoped or transient dependencies -- ASP.NET Core resolves those parameters from the current request's DI scope on each call.

With IMiddleware, the interface implementation is resolved fresh from the DI container on each request. Register it as Transient or Scoped to match its dependency requirements. A Scoped IMiddleware gets a new instance per request, which means it can safely depend on Scoped services like DbContext. This is one of the clearest advantages of IMiddleware over the convention-based approach -- the dependency injection container's reflection-based resolution handles lifetime management correctly without any manual scope management in your middleware code.

Ultimate Starter Guide to Middleware in ASP.NET Core: Everything You Need to Know

Discover the benefits of middleware in ASP.NET Core, including flexibility and modularity. Learn about middleware like authentication and logging!

Custom Middleware in ASP.NET Core - How to Harness the Power!

Learn about different types of middleware and how to implement custom middleware in ASP.NET Core to solve common challenges in web development!

API Key Authentication Middleware In ASP NET Core - A How To Guide

Want to add API key authentication middleware into your ASP.NET Core application? Check out this article for a simple code example that shows you how!

An error has occurred. This application may no longer respond until reloaded. Reload