Mastering ASP.NET Core Middleware: From Basics to Advanced (.NET 10)
A comprehensive deep dive into ASP.NET Core Middleware. Learn how the request pipeline works, how to build high-performance custom middleware, and implement real-world patterns like Multi-Tenancy, Request Logging, and API Key Authentication.
Mastering ASP.NET Core Middleware: From Basics to Advanced (.NET 10)
Introduction
In ASP.NET Core, Middleware is the software assembled into an application pipeline to handle requests and responses. Think of it as a series of "stations" on an assembly line. Each component chooses whether to pass the request to the next component in the pipeline and can perform work before and after the next component in the pipeline is invoked.
If you want to master ASP.NET Core, you must master the middleware pipeline. It is the backbone of how your application processes every single HTTP request.
In this guide, we will move from the basic definitions to advanced, real-world implementations suitable for high-scale production systems in .NET 10.
1. What is Middleware and Why Do We Need It?
The "Russian Doll" Model
Middleware follows a specific execution pattern. When a request comes in:
- Logic Before: Middleware A runs code.
- Next: Middleware A calls
next(), passing control to Middleware B. - Logic Before: Middleware B runs code.
- ...Endpoint: Eventually, the request hits your Controller or Minimal API endpoint.
- Logic After: The response travels back up the pipeline. Middleware B runs code.
- Logic After: Middleware A runs code.
- Response: Sent to the client.
The Purpose
Middleware solves Cross-Cutting Concerns—logic that applies to many or all parts of your application, such as:
- Authentication & Authorization: "Who are you, and are you allowed here?"
- Logging & Diagnostics: "What happened during this request?"
- Error Handling: "Something broke, let's handle it gracefully."
- CORS: "Can this browser website call my API?"
- Compression: "Let's shrink the response size."
Without middleware, you would have to repeat this logic in every single Controller action.
2. The Basics: Use, Run, and Map
In Program.cs, you configure the pipeline. The order matters tremendously.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// 1. Standard Middleware (Chained)
app.Use(async (context, next) =>
{
Console.WriteLine("Before Request");
await next.Invoke(); // Call the next guy
Console.WriteLine("After Request");
});
// 2. Branching Middleware
app.Map("/admin", adminApp =>
{
adminApp.Run(async context =>
{
await context.Response.WriteAsync("Admin Area");
});
});
// 3. Terminal Middleware (Ends the pipeline)
app.Run(async context =>
{
await context.Response.WriteAsync("Hello World");
});
app.Run();
3. Creating Custom Middleware (The Professional Way)
While inline lambdas (app.Use(...)) are fine for testing, production code uses dedicated classes. There are two main ways to implement custom middleware.
Approach A: Convention-Based (Standard)
This is the most common approach. It requires:
- A constructor accepting
RequestDelegate. - An
InvokeAsyncmethod acceptingHttpContext.
Pros: Singleton lifetime (usually), high performance.
Cons: Dependencies injected into the constructor are Singletons. Scoped dependencies (like DbContext) must be injected into InvokeAsync.
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 = System.Diagnostics.Stopwatch.StartNew();
try
{
// Continue down the pipeline
await _next(context);
}
finally
{
sw.Stop();
var elapsed = sw.ElapsedMilliseconds;
if (elapsed > 500)
{
_logger.LogWarning("Slow Request: {Method} {Path} took {Elapsed}ms",
context.Request.Method, context.Request.Path, elapsed);
}
}
}
}
// Extension method for clean Program.cs
public static class RequestTimingExtensions
{
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder builder)
{
return builder.UseMiddleware<RequestTimingMiddleware>();
}
}
Approach B: Factory-Based (IMiddleware)
This approach implements the IMiddleware interface.
Pros: Strong typing, the middleware class itself is resolved from DI (can be Scoped). Cons: Slightly more overhead than convention-based.
public class FactoryBasedMiddleware : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
await next(context);
}
}
4. Real-World Example 1: Multi-Tenancy Resolution
In a SaaS application, you often need to know which tenant (customer) is making the request. This is perfect for middleware.
Scenario: We identify the tenant via a header X-Tenant-ID. We want to set this tenant into a scoped service so Controllers can access it easily.
// 1. The Tenant Context Service
public class TenantContext
{
public string? TenantId { get; set; }
}
// 2. The Middleware
public class TenantResolutionMiddleware
{
private readonly RequestDelegate _next;
public TenantResolutionMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, TenantContext tenantContext)
{
// Check header
if (context.Request.Headers.TryGetValue("X-Tenant-ID", out var tenantId))
{
tenantContext.TenantId = tenantId;
}
else
{
// Optional: Reject request if tenant is missing
// context.Response.StatusCode = 400;
// await context.Response.WriteAsync("Missing Tenant ID");
// return;
}
// Call next middleware
await _next(context);
}
}
// 3. Registration
// Program.cs
builder.Services.AddScoped<TenantContext>();
// ...
app.UseMiddleware<TenantResolutionMiddleware>();
5. Real-World Example 2: API Key Authentication
Sometimes you don't need full OAuth2. A simple API Key for server-to-server communication suffices.
public class ApiKeyMiddleware
{
private readonly RequestDelegate _next;
private const string APIKEYNAME = "X-API-Key";
// In real life, fetch this from config or a secure vault
private const string VALID_API_KEY = "my-secret-key-123";
public ApiKeyMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
if (!context.Request.Headers.TryGetValue(APIKEYNAME, out var extractedApiKey))
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("API Key was not provided.");
return; // Short-circuit! Pipeline stops here.
}
if (!VALID_API_KEY.Equals(extractedApiKey))
{
context.Response.StatusCode = 403;
await context.Response.WriteAsync("Unauthorized client.");
return;
}
await _next(context);
}
}
6. Real-World Example 3: Request/Response Body Logging (Advanced)
This is a "Master Level" topic. Reading the request/response body in middleware is tricky because streams in ASP.NET Core are optimized to be read-once and non-seekable by default.
The Challenge: If you read the request body stream to log it, the stream position moves to the end. When the Controller tries to read it later, it finds an empty stream.
The Solution: We must enable buffering.
public class RequestResponseLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestResponseLoggingMiddleware> _logger;
public RequestResponseLoggingMiddleware(RequestDelegate next, ILogger<RequestResponseLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
// 1. Handle Request Logging
context.Request.EnableBuffering(); // Critical! Allows re-reading the stream
var requestBody = await ReadStreamAsync(context.Request.Body);
_logger.LogInformation("Incoming Request: {Method} {Path} Body: {Body}",
context.Request.Method, context.Request.Path, requestBody);
// Reset position so the Controller can read it
context.Request.Body.Position = 0;
// 2. Handle Response Logging
// We need to swap the original response stream with a memory stream
// so we can read it before sending it to the client.
var originalBodyStream = context.Response.Body;
using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
// Continue pipeline
await _next(context);
// 3. Read the response
context.Response.Body.Seek(0, SeekOrigin.Begin);
var responseText = await new StreamReader(context.Response.Body).ReadToEndAsync();
_logger.LogInformation("Outgoing Response: {StatusCode} Body: {Body}",
context.Response.StatusCode, responseText);
// 4. Copy the contents back to the original stream
context.Response.Body.Seek(0, SeekOrigin.Begin);
await responseBody.CopyToAsync(originalBodyStream);
}
private static async Task<string> ReadStreamAsync(Stream stream)
{
stream.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(stream, leaveOpen: true);
return await reader.ReadToEndAsync();
}
}
⚠️ Warning: Logging full bodies in production can leak PII (passwords, credit cards) and fill up disk space. Always implement sanitization and length limits in a real system.
7. Advanced: Branching the Pipeline
Sometimes you want specific middleware to run only for certain endpoints.
MapWhen
MapWhen branches the pipeline based on a predicate.
app.MapWhen(context => context.Request.Query.ContainsKey("debug"), appBranch =>
{
appBranch.UseMiddleware<RequestTimingMiddleware>();
// Note: If you don't merge back, this branch ends here.
appBranch.Run(async context => await context.Response.WriteAsync("Debug Mode Active"));
});
UseWhen
UseWhen branches the pipeline but rejoins the main pipeline afterwards. This is usually what you want.
// Only log requests starting with /api
app.UseWhen(context => context.Request.Path.StartsWithSegments("/api"), appBranch =>
{
appBranch.UseMiddleware<RequestResponseLoggingMiddleware>();
});
8. Putting It All Together: The Complete Program.cs
Here is how you wire up all these middleware components in your Program.cs. Remember, order is critical.
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = WebApplication.CreateBuilder(args);
// 1. Register Services (DI)
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Register Scoped Services for Middleware
builder.Services.AddScoped<TenantContext>();
// Register Middleware Classes (if using Factory-based or needing DI)
builder.Services.AddTransient<RequestResponseLoggingMiddleware>();
var app = builder.Build();
// 2. Configure the HTTP Request Pipeline (Middleware Order)
// A. Exception Handling (Always First)
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
// B. HTTPS Redirection
app.UseHttpsRedirection();
// C. Custom Logging (Before Auth, so we log 401/403s)
// Note: UseWhen branches the pipeline. If you want it to run for everything, just use app.UseMiddleware<T>()
app.UseWhen(context => context.Request.Path.StartsWithSegments("/api"), appBuilder =>
{
appBuilder.UseMiddleware<RequestResponseLoggingMiddleware>();
});
// D. Tenant Resolution (Before Auth, in case Auth depends on Tenant)
app.UseMiddleware<TenantResolutionMiddleware>();
// E. Authentication & Authorization (The Gatekeepers)
// app.UseMiddleware<ApiKeyMiddleware>(); // Uncomment to enforce API Key
app.UseAuthentication();
app.UseAuthorization();
// F. Standard MVC / API Routing
app.MapControllers();
// G. Terminal Middleware (Fallback)
app.Run(async context =>
{
context.Response.StatusCode = 404;
await context.Response.WriteAsync("Resource not found.");
});
app.Run();
9. Best Practices Checklist
- Order Matters: ExceptionHandler should be first. Auth should be before Controllers.
- Avoid Blocking Code: Never use
.Resultor.Wait(). Always useawait. - Scoped Services: Do not inject Scoped services into the Constructor of a Convention-based middleware. Inject them into the
InvokeAsyncmethod. - Short-Circuiting: If your middleware handles the request (e.g., returns 400 Bad Request), do not call
next(). - Performance: Middleware runs for every request. Keep it lightweight. Avoid heavy database calls unless necessary.
Conclusion
Middleware is the superpower of ASP.NET Core. It allows you to build modular, clean, and maintainable applications by separating cross-cutting concerns from business logic. Whether you are building simple API key validation or complex multi-tenant resolution, understanding the pipeline is essential for any senior .NET developer.
Related Articles
Dockerizing ASP.NET Core: From Basics to Production
A complete guide to containerizing ASP.NET Core applications. Learn standard patterns, security best practices, and how to use Chiseled Ubuntu images.
Docker Mastery Part 4: Real-World Scenarios and Microservices
Apply your knowledge to real-world scenarios. We'll setup a full-stack Web App with a Database and explore Microservices architecture.
Full Stack Insights
Software Engineer
Passionate about software development, architecture, and sharing knowledge.
Quick Links
Full Stack Insights
Software Engineer
Passionate about software development, architecture, and sharing knowledge with the community.