ASP.NET Core Middleware Pipeline: Order, Custom Middleware, and Best Practices

ASP.NET Core Middleware Pipeline: Order, Custom Middleware, and Best Practices

Introduction

The middleware pipeline is the heart of ASP.NET Core request processing. Every HTTP request flows through a series of middleware components that can read, modify, or short-circuit the request and response. Understanding middleware order, creating custom components, and optimizing the pipeline is essential for building performant and maintainable applications.

Middleware Pipeline Basics

Request Processing Flow

Pipeline execution:

Client Request
    ↓
Exception Handling Middleware
    ↓
HTTPS Redirection
    ↓
Static Files
    ↓
Authentication
    ↓
Authorization
    ↓
Custom Middleware
    ↓
Routing
    ↓
Endpoint Middleware
    ↓
Application Code
    ↓
Response
    ↓
Back through middleware chain
    ↓
Client Response

Standard Middleware Order

Program.cs configuration:

var app = builder.Build();

// 1. Exception handling - must be first
app.UseExceptionHandler("/error");
app.UseHsts();

// 2. HTTPS redirection
app.UseHttpsRedirection();

// 3. Static files - early to short-circuit
app.UseStaticFiles();

// 4. Routing - matches endpoints
app.UseRouting();

// 5. CORS - after routing, before auth
app.UseCors("AllowAll");

// 6. Authentication - identifies user
app.UseAuthentication();

// 7. Authorization - verifies permissions
app.UseAuthorization();

// 8. Custom middleware
app.UseMiddleware<RequestTimingMiddleware>();

// 9. Session - after authentication
app.UseSession();

// 10. Response caching
app.UseResponseCaching();

// 11. Response compression - near the end
app.UseResponseCompression();

// 12. Endpoints - must be last
app.MapControllers();
app.MapRazorPages();

app.Run();

Custom Middleware

Inline Middleware

Simple inline middleware:

app.Use(async (context, next) =>
{
    // Before next middleware
    Console.WriteLine($"Request: {context.Request.Path}");
    
    await next(context);
    
    // After next middleware
    Console.WriteLine($"Response: {context.Response.StatusCode}");
});

Convention-Based Middleware

Request timing middleware:

public class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestTimingMiddleware> _logger;
    
    public RequestTimingMiddleware(
        RequestDelegate next,
        ILogger<RequestTimingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }
    
    public async Task InvokeAsync(HttpContext context)
    {
        var sw = Stopwatch.StartNew();
        
        try
        {
            await _next(context);
        }
        finally
        {
            sw.Stop();
            _logger.LogInformation(
                "Request {Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}",
                context.Request.Method,
                context.Request.Path,
                sw.ElapsedMilliseconds,
                context.Response.StatusCode);
        }
    }
}

// Extension method
public static class RequestTimingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestTiming(
        this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<RequestTimingMiddleware>();
    }
}

// Usage
app.UseRequestTiming();

Factory-Based Middleware

With dependencies:

public class ApiKeyMiddleware : IMiddleware
{
    private readonly ILogger<ApiKeyMiddleware> _logger;
    private readonly IApiKeyValidator _validator;
    
    public ApiKeyMiddleware(
        ILogger<ApiKeyMiddleware> logger,
        IApiKeyValidator validator)
    {
        _logger = logger;
        _validator = validator;
    }
    
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        if (!context.Request.Headers.TryGetValue("X-API-Key", out var apiKey))
        {
            context.Response.StatusCode = 401;
            await context.Response.WriteAsync("API Key missing");
            return;
        }
        
        if (!await _validator.IsValidAsync(apiKey!))
        {
            _logger.LogWarning("Invalid API key: {ApiKey}", apiKey);
            context.Response.StatusCode = 401;
            await context.Response.WriteAsync("Invalid API Key");
            return;
        }
        
        await next(context);
    }
}

// Registration
builder.Services.AddScoped<ApiKeyMiddleware>();
builder.Services.AddScoped<IApiKeyValidator, ApiKeyValidator>();

// Usage
app.UseMiddleware<ApiKeyMiddleware>();

Request and Response Modification

Reading Request Body

Buffering for multiple reads:

public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;
    
    public RequestLoggingMiddleware(
        RequestDelegate next,
        ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }
    
    public async Task InvokeAsync(HttpContext context)
    {
        // Enable buffering to read request body multiple times
        context.Request.EnableBuffering();
        
        string requestBody = string.Empty;
        
        if (context.Request.ContentLength > 0)
        {
            using var reader = new StreamReader(
                context.Request.Body,
                encoding: Encoding.UTF8,
                detectEncodingFromByteOrderMarks: false,
                bufferSize: 1024,
                leaveOpen: true);
            
            requestBody = await reader.ReadToEndAsync();
            
            // Reset position for next middleware
            context.Request.Body.Position = 0;
        }
        
        _logger.LogInformation(
            "Request {Method} {Path}: {Body}",
            context.Request.Method,
            context.Request.Path,
            requestBody);
        
        await _next(context);
    }
}

Modifying Response

Response wrapping:

public class ResponseWrapperMiddleware
{
    private readonly RequestDelegate _next;
    
    public ResponseWrapperMiddleware(RequestDelegate next)
    {
        _next = next;
    }
    
    public async Task InvokeAsync(HttpContext context)
    {
        var originalBody = context.Response.Body;
        
        try
        {
            using var memoryStream = new MemoryStream();
            context.Response.Body = memoryStream;
            
            await _next(context);
            
            memoryStream.Position = 0;
            var responseBody = await new StreamReader(memoryStream).ReadToEndAsync();
            
            var wrappedResponse = new
            {
                success = context.Response.StatusCode < 400,
                statusCode = context.Response.StatusCode,
                data = responseBody,
                timestamp = DateTime.UtcNow
            };
            
            var json = JsonSerializer.Serialize(wrappedResponse);
            var bytes = Encoding.UTF8.GetBytes(json);
            
            context.Response.Body = originalBody;
            context.Response.ContentLength = bytes.Length;
            context.Response.ContentType = "application/json";
            
            await context.Response.Body.WriteAsync(bytes);
        }
        finally
        {
            context.Response.Body = originalBody;
        }
    }
}

Conditional Middleware

UseWhen for Branching

Conditional execution:

// Apply middleware only to API requests
app.UseWhen(
    context => context.Request.Path.StartsWithSegments("/api"),
    appBuilder =>
    {
        appBuilder.UseMiddleware<ApiKeyMiddleware>();
        appBuilder.UseMiddleware<RateLimitingMiddleware>();
    });

// Different middleware for admin paths
app.UseWhen(
    context => context.Request.Path.StartsWithSegments("/admin"),
    appBuilder =>
    {
        appBuilder.UseMiddleware<AdminAuthenticationMiddleware>();
        appBuilder.UseMiddleware<AuditLoggingMiddleware>();
    });

MapWhen for Branching

Separate pipeline:

app.MapWhen(
    context => context.Request.Path.StartsWithSegments("/health"),
    appBuilder =>
    {
        // Health check pipeline - minimal middleware
        appBuilder.Run(async context =>
        {
            context.Response.ContentType = "application/json";
            await context.Response.WriteAsync("{\"status\":\"healthy\"}");
        });
    });

// Main pipeline continues for non-health requests
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

Authentication and Authorization Flow

JWT Authentication Middleware

Custom JWT validation:

public class JwtMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IConfiguration _configuration;
    
    public JwtMiddleware(RequestDelegate next, IConfiguration configuration)
    {
        _next = next;
        _configuration = configuration;
    }
    
    public async Task InvokeAsync(HttpContext context, IUserService userService)
    {
        var token = context.Request.Headers["Authorization"]
            .FirstOrDefault()?.Split(" ").Last();
        
        if (token != null)
        {
            await AttachUserToContext(context, userService, token);
        }
        
        await _next(context);
    }
    
    private async Task AttachUserToContext(
        HttpContext context,
        IUserService userService,
        string token)
    {
        try
        {
            var tokenHandler = new JwtSecurityTokenHandler();
            var key = Encoding.ASCII.GetBytes(_configuration["Jwt:Secret"]!);
            
            tokenHandler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(key),
                ValidateIssuer = true,
                ValidIssuer = _configuration["Jwt:Issuer"],
                ValidateAudience = true,
                ValidAudience = _configuration["Jwt:Audience"],
                ClockSkew = TimeSpan.Zero
            }, out SecurityToken validatedToken);
            
            var jwtToken = (JwtSecurityToken)validatedToken;
            var userId = int.Parse(jwtToken.Claims.First(x => x.Type == "id").Value);
            
            // Attach user to context
            context.Items["User"] = await userService.GetByIdAsync(userId);
        }
        catch
        {
            // Token validation failed - user not attached to context
        }
    }
}

// Attribute for protected endpoints
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthorizeAttribute : Attribute, IAuthorizationFilter
{
    public void OnAuthorization(AuthorizationFilterContext context)
    {
        var user = context.HttpContext.Items["User"];
        
        if (user == null)
        {
            context.Result = new JsonResult(new { message = "Unauthorized" })
            {
                StatusCode = StatusCodes.Status401Unauthorized
            };
        }
    }
}

Error Handling

Global Exception Handler

Centralized error handling:

public class GlobalExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<GlobalExceptionMiddleware> _logger;
    private readonly IHostEnvironment _env;
    
    public GlobalExceptionMiddleware(
        RequestDelegate next,
        ILogger<GlobalExceptionMiddleware> logger,
        IHostEnvironment env)
    {
        _next = next;
        _logger = logger;
        _env = env;
    }
    
    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception");
            await HandleExceptionAsync(context, ex);
        }
    }
    
    private async Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        context.Response.ContentType = "application/json";
        
        var response = exception switch
        {
            ValidationException => (
                StatusCodes.Status400BadRequest,
                new { error = exception.Message, errors = ((ValidationException)exception).Errors }
            ),
            NotFoundException => (
                StatusCodes.Status404NotFound,
                new { error = exception.Message }
            ),
            UnauthorizedException => (
                StatusCodes.Status401Unauthorized,
                new { error = "Unauthorized" }
            ),
            _ => (
                StatusCodes.Status500InternalServerError,
                new { error = _env.IsDevelopment() ? exception.ToString() : "Internal server error" }
            )
        };
        
        context.Response.StatusCode = response.Item1;
        await context.Response.WriteAsJsonAsync(response.Item2);
    }
}

Status Code Pages

Custom error responses:

app.UseStatusCodePages(async context =>
{
    var response = context.HttpContext.Response;
    
    if (response.StatusCode == 404)
    {
        response.ContentType = "application/json";
        await response.WriteAsJsonAsync(new
        {
            statusCode = 404,
            message = "Resource not found"
        });
    }
    else if (response.StatusCode >= 400 && response.StatusCode < 500)
    {
        response.ContentType = "application/json";
        await response.WriteAsJsonAsync(new
        {
            statusCode = response.StatusCode,
            message = "Client error"
        });
    }
});

Short-Circuiting

Terminal Middleware

Stopping pipeline execution:

// Maintenance mode check
app.Use(async (context, next) =>
{
    if (IsMaintenanceMode())
    {
        context.Response.StatusCode = 503;
        await context.Response.WriteAsync("Service under maintenance");
        return; // Don't call next
    }
    
    await next(context);
});

// Health check endpoint
app.Map("/health", appBuilder =>
{
    appBuilder.Run(async context =>
    {
        await context.Response.WriteAsync("Healthy");
        // Pipeline ends here for /health requests
    });
});

Performance Considerations

Response Caching

Caching middleware:

builder.Services.AddResponseCaching();

var app = builder.Build();

app.UseResponseCaching();

// Use with attributes
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)]
public IActionResult GetProducts()
{
    return Ok(_products);
}

Response Compression

Enable compression:

builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
    options.Providers.Add<BrotliCompressionProvider>();
    options.Providers.Add<GzipCompressionProvider>();
});

builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
    options.Level = CompressionLevel.Fastest;
});

var app = builder.Build();

// Near end of pipeline
app.UseResponseCompression();

Best Practices

  1. Order Matters: Exception handling first, endpoints last
  2. Short-Circuit Early: Static files and health checks before expensive middleware
  3. Use Extension Methods: Encapsulate middleware registration
  4. Minimize Buffering: Read request/response bodies only when necessary
  5. Leverage Scoped Services: Use IMiddleware for dependency injection
  6. Test Middleware: Unit test with DefaultHttpContext
  7. Monitor Performance: Track middleware execution time

Troubleshooting

Middleware Not Executing:

// ❌ Order matters - endpoints terminate pipeline
app.MapControllers();
app.UseMiddleware<MyMiddleware>(); // Won't execute!

// ✅ Correct order
app.UseMiddleware<MyMiddleware>();
app.MapControllers();

Request Body Already Read:

// ✅ Enable buffering for multiple reads
context.Request.EnableBuffering();

Key Takeaways

  • Middleware order is critical for correct request processing
  • Custom middleware enables cross-cutting concerns like logging and authentication
  • Request/response modification requires careful stream handling
  • Conditional middleware reduces overhead for specific request paths
  • Short-circuiting improves performance for simple endpoints

Next Steps

  • Implement health checks with custom middleware
  • Add correlation IDs for distributed tracing
  • Build rate limiting middleware with Redis
  • Create API versioning middleware

Additional Resources


The pipeline is the application.