dotnet-middleware-patterns
dotnet-middleware-patterns
ASP.NET Core middleware patterns for the HTTP request pipeline. Covers correct ordering, writing custom middleware as classes or inline delegates, short-circuit logic, request/response manipulation, exception handling middleware, and conditional middleware registration.
Scope
- Correct middleware pipeline ordering and common ordering mistakes
- Custom middleware classes (convention-based and IMiddleware)
- Inline middleware (Use, Run, Map)
- Short-circuit logic for early validation and feature flags
- Request/response body manipulation
- Exception handling middleware (IExceptionHandler, StatusCodePages)
- Conditional middleware (UseWhen, MapWhen)
Out of scope
- Authentication/authorization middleware configuration -- see [skill:dotnet-api-security]
- Observability middleware (OpenTelemetry, health checks) -- see [skill:dotnet-observability]
- Minimal API endpoint filters -- see [skill:dotnet-minimal-apis]
Cross-references: [skill:dotnet-observability] for logging and telemetry middleware, [skill:dotnet-api-security] for auth middleware, [skill:dotnet-minimal-apis] for endpoint filters (the Minimal API equivalent of middleware).
Pipeline Ordering
Middleware executes in the order it is registered. The order is critical -- placing middleware in the wrong position causes subtle bugs (missing CORS headers, unhandled exceptions, auth bypasses).
Recommended Order
var app = builder.Build();
// 1. Exception handling (outermost -- catches everything below)
app.UseExceptionHandler("/error");
// 2. HSTS (before any response is sent)
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
// 3. HTTPS redirection
app.UseHttpsRedirection();
// 4. Static files (short-circuits for static content before routing)
app.UseStaticFiles();
// 5. Routing (matches endpoints but does not execute them yet)
// .NET 6+ calls UseRouting() implicitly if omitted; shown here for clarity
app.UseRouting();
// 6. CORS (must be after routing, before auth)
app.UseCors();
// 7. Authentication (identifies the user)
app.UseAuthentication();
// 8. Authorization (checks permissions against the matched endpoint)
app.UseAuthorization();
// 9. Custom middleware (runs after auth, before endpoint execution)
app.UseRequestLogging();
// 10. Endpoint execution (terminal -- executes the matched endpoint)
app.MapControllers();
app.MapRazorPages();
Why Order Matters
| Mistake | Consequence |
|---|---|
UseAuthorization() before UseRouting() |
Authorization has no endpoint metadata -- all requests pass |
UseCors() after UseAuthorization() |
Preflight requests fail because they lack auth tokens |
UseExceptionHandler() after custom middleware |
Exceptions in custom middleware are unhandled |
UseStaticFiles() after UseAuthorization() |
Static files require authentication unnecessarily |
Custom Middleware Classes
Convention-based middleware uses a constructor with RequestDelegate and an InvokeAsync method. This is the standard pattern for reusable middleware.
Basic Pattern
public sealed 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 stopwatch = Stopwatch.StartNew();
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
_logger.LogInformation(
"Request {Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}",
context.Request.Method,
context.Request.Path,
stopwatch.ElapsedMilliseconds,
context.Response.StatusCode);
}
}
}
// Registration via extension method (conventional pattern)
public static class RequestTimingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(
this IApplicationBuilder app)
=> app.UseMiddleware<RequestTimingMiddleware>();
}
// Usage in Program.cs
app.UseRequestTiming();
Factory-Based (IMiddleware)
For middleware that requires scoped services, implement IMiddleware. This uses DI to create middleware instances per-request instead of once at startup:
public sealed class TenantMiddleware : IMiddleware
{
private readonly TenantDbContext _db;
// Scoped services can be injected directly
public TenantMiddleware(TenantDbContext db)
{
_db = db;
}
public async Task InvokeAsync(
HttpContext context, RequestDelegate next)
{
var tenantId = context.Request.Headers["X-Tenant-Id"]
.FirstOrDefault();
if (tenantId is not null)
{
var tenant = await _db.Tenants.FindAsync(tenantId);
context.Items["Tenant"] = tenant;
}
await next(context);
}
}
// IMiddleware requires explicit DI registration
builder.Services.AddScoped<TenantMiddleware>();
// Then register in pipeline
app.UseMiddleware<TenantMiddleware>();
Convention-based vs IMiddleware:
| Aspect | Convention-based | IMiddleware |
|---|---|---|
| Lifetime | Singleton (created once) | Per-request (from DI) |
| Scoped services | Via InvokeAsync parameters only |
Via constructor injection |
| Registration | UseMiddleware<T>() only |
Requires services.Add*<T>() + UseMiddleware<T>() |
| Performance | Slightly faster (no per-request allocation) | Resolved from DI each request (lifetime depends on registration) |
Inline Middleware
For simple, one-off middleware logic, use app.Use(), app.Map(), or app.Run():
app.Use -- Pass-Through
// Adds a header to every response, then passes to next middleware
app.Use(async (context, next) =>
{
context.Response.Headers["X-Request-Id"] =
context.TraceIdentifier;
await next(context);
});
app.Run -- Terminal
// Terminal middleware -- does NOT call next
app.Run(async context =>
{
await context.Response.WriteAsync("Fallback response");
});
app.Map -- Branch by Path
// Branch the pipeline for requests matching /api/diagnostics
app.Map("/api/diagnostics", diagnosticApp =>
{
diagnosticApp.Run(async context =>
{
var data = new
{
MachineName = Environment.MachineName,
Timestamp = DateTimeOffset.UtcNow
};
await context.Response.WriteAsJsonAsync(data);
});
});
Short-Circuit Logic
Middleware can short-circuit the pipeline by not calling next(). Use this for early validation, rate limiting, or feature flags.
Request Validation
public sealed class ApiKeyMiddleware
{
private readonly RequestDelegate _next;
private readonly string _expectedKey;
public ApiKeyMiddleware(
RequestDelegate next,
IConfiguration config)
{
_next = next;
_expectedKey = config["ApiKey"]
?? throw new InvalidOperationException(
"ApiKey configuration is required");
}
public async Task InvokeAsync(HttpContext context)
{
if (!context.Request.Headers.TryGetValue(
"X-Api-Key", out var providedKey)
|| !string.Equals(
providedKey, _expectedKey, StringComparison.Ordinal))
{
context.Response.StatusCode =
StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new
{
Error = "Invalid or missing API key"
});
return; // Short-circuit -- do NOT call _next
}
await _next(context);
}
}
Feature Flag Gate
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/beta"),
betaApp =>
{
betaApp.Use(async (context, next) =>
{
var featureManager = context.RequestServices
.GetRequiredService<IFeatureManager>();
if (!await featureManager.IsEnabledAsync("BetaFeatures"))
{
context.Response.StatusCode =
StatusCodes.Status404NotFound;
return; // Short-circuit
}
await next(context);
});
});
Request and Response Manipulation
Reading the Request Body
The request body is a forward-only stream by default. Enable buffering to read it multiple times:
public sealed 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 so the body can be read multiple times
context.Request.EnableBuffering();
if (context.Request.ContentLength > 0
&& context.Request.ContentLength < 64_000)
{
context.Request.Body.Position = 0;
using var reader = new StreamReader(
context.Request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
_logger.LogDebug(
"Request body for {Path}: {Body}",
context.Request.Path, body);
context.Request.Body.Position = 0; // Reset for next reader
}
await _next(context);
}
}
Modifying the Response
To capture or modify the response body, replace context.Response.Body with a MemoryStream:
public async Task InvokeAsync(HttpContext context)
{
var originalBodyStream = context.Response.Body;
using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
await _next(context);
// Read the response written by downstream middleware
context.Response.Body.Seek(0, SeekOrigin.Begin);
var responseText = await new StreamReader(
context.Response.Body).ReadToEndAsync();
context.Response.Body.Seek(0, SeekOrigin.Begin);
// Copy back to original stream
await responseBody.CopyToAsync(originalBodyStream);
}
Caution: Response body replacement adds memory overhead and should only be used for diagnostics or specific transformation requirements, not in high-throughput paths.
Exception Handling Middleware
Built-in Exception Handler
ASP.NET Core provides UseExceptionHandler for production-grade exception handling. This should always be the outermost middleware:
app.UseExceptionHandler(exceptionApp =>
{
exceptionApp.Run(async context =>
{
context.Response.StatusCode =
StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var exceptionFeature = context.Features
.Get<IExceptionHandlerFeature>();
var logger = context.RequestServices
.GetRequiredService<ILogger<Program>>();
logger.LogError(
exceptionFeature?.Error,
"Unhandled exception for {Path}",
context.Request.Path);
await context.Response.WriteAsJsonAsync(new
{
Error = "An internal error occurred",
TraceId = context.TraceIdentifier
});
});
});
IExceptionHandler (.NET 8+)
.NET 8 introduced IExceptionHandler for DI-friendly, composable exception handling. Multiple handlers can be registered and are invoked in order until one handles the exception:
public sealed class ValidationExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext context,
Exception exception,
CancellationToken ct)
{
if (exception is not ValidationException validationException)
return false; // Not handled -- pass to next handler
context.Response.StatusCode =
StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new
{
Error = "Validation failed",
Details = validationException.Errors
}, ct);
return true; // Handled -- stop the chain
}
}
public sealed class GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext context,
Exception exception,
CancellationToken ct)
{
logger.LogError(exception, "Unhandled exception");
context.Response.StatusCode =
StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new
{
Error = "An internal error occurred",
TraceId = context.TraceIdentifier
}, ct);
return true;
}
}
// Register handlers in order (first match wins)
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
StatusCodePages for Non-Exception Errors
For HTTP error status codes that are not caused by exceptions (404, 403), use UseStatusCodePages:
app.UseStatusCodePagesWithReExecute("/error/{0}");
// Or inline
app.UseStatusCodePages(async context =>
{
context.HttpContext.Response.ContentType = "application/json";
await context.HttpContext.Response.WriteAsJsonAsync(new
{
Error = $"HTTP {context.HttpContext.Response.StatusCode}",
TraceId = context.HttpContext.TraceIdentifier
});
});
Conditional Middleware
UseWhen -- Conditional Branch (Rejoins Pipeline)
UseWhen branches the pipeline based on a predicate. The branch rejoins the main pipeline after execution:
// Only apply rate limiting headers for API routes
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/api"),
apiApp =>
{
// Requires builder.Services.AddRateLimiter() in service registration
apiApp.UseRateLimiter();
});
MapWhen -- Conditional Branch (Does Not Rejoin)
MapWhen creates a terminal branch that does not rejoin the main pipeline:
// Serve a special handler for WebSocket upgrade requests
app.MapWhen(
context => context.WebSockets.IsWebSocketRequest,
wsApp =>
{
wsApp.Run(async context =>
{
using var ws = await context.WebSockets
.AcceptWebSocketAsync();
// Handle WebSocket connection
});
});
Environment-Specific Middleware
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI();
}
else
{
app.UseExceptionHandler("/error");
app.UseHsts();
}
Key Principles
- Order is everything -- middleware executes top-to-bottom for requests and bottom-to-top for responses; incorrect order causes auth bypasses, missing headers, and unhandled exceptions
- Exception handler goes first --
UseExceptionHandlermust be the outermost middleware to catch exceptions from all downstream components - Prefer classes over inline for reusable middleware -- convention-based middleware classes are testable, composable, and follow the single-responsibility principle
- Use
IMiddlewarefor scoped dependencies -- convention-based middleware is singleton; if you need scoped services (DbContext, user-scoped caches), useIMiddleware - Short-circuit intentionally -- always document why a middleware does not call
next()and ensure it writes a complete response - Avoid response body manipulation in hot paths -- replacing
Response.BodywithMemoryStreamdoubles memory usage per request
Agent Gotchas
- Do not place
UseAuthorization()beforeUseRouting()-- authorization requires endpoint metadata from routing to evaluate policies. Without routing, all authorization checks are skipped. - Do not place
UseCors()afterUseAuthorization()-- CORS preflight (OPTIONS) requests do not carry auth tokens. If auth runs first, preflights are rejected with 401. - Do not forget to call
next()in pass-through middleware -- forgettingawait _next(context)silently short-circuits the pipeline, causing downstream middleware and endpoints to never execute. - Do not read
Request.BodywithoutEnableBuffering()-- the request body stream is forward-only by default. Reading it without buffering consumes it, causing model binding and subsequent reads to fail with empty data. - Do not register
IMiddlewareimplementations without DI registration -- unlike convention-based middleware,IMiddlewarerequires explicitservices.AddScoped<T>()orservices.AddTransient<T>(). Without it,UseMiddleware<T>()throws at startup. - Do not write to
Response.Bodyafter callingnext()if downstream middleware has already started the response -- once headers are sent (response has started), modifications throwInvalidOperationException. Checkcontext.Response.HasStartedbefore writing.
Knowledge Sources
Middleware patterns in this skill are grounded in publicly available content from:
- Andrew Lock's "Exploring ASP.NET Core" Blog Series -- Deep coverage of middleware authoring patterns, including IMiddleware vs convention-based trade-offs, pipeline ordering pitfalls, endpoint routing internals, and IExceptionHandler composition. Source: https://andrewlock.net/
- Official ASP.NET Core Middleware Documentation -- Middleware fundamentals, factory-based activation, and error handling patterns. Source: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/
Note: This skill applies publicly documented guidance. It does not represent or speak for the named sources.
References
- ASP.NET Core middleware
- Write custom ASP.NET Core middleware
- Factory-based middleware activation
- Handle errors in ASP.NET Core
- IExceptionHandler in .NET 8
- Exploring ASP.NET Core (Andrew Lock)
Attribution
Adapted from Aaronontheweb/dotnet-skills (MIT license).
More from novotnyllc/dotnet-artisan
dotnet-csharp
Baseline C# skill loaded for every .NET code path. Guides language patterns (records, pattern matching, primary constructors, C# 8-15), coding standards, async/await, DI, LINQ, serialization, domain modeling, concurrency, Roslyn analyzers, globalization, native interop (P/Invoke, LibraryImport, ComWrappers), WASM interop (JSImport/JSExport), and type design. Spans 25 topics. Do not use for ASP.NET endpoint architecture, UI framework patterns, or CI/CD guidance.
129dotnet-ui
Builds .NET UI apps across Blazor (Server, WASM, Hybrid, Auto), MAUI (XAML, MVVM, Shell, Native AOT), Uno Platform (MVUX, Extensions, Toolkit), WPF (.NET 8+, Fluent theme), WinUI 3 (Windows App SDK, MSIX, Mica/Acrylic, adaptive layout), and WinForms (high-DPI, dark mode) with JS interop, accessibility (SemanticProperties, ARIA), localization (.resx, RTL), platform bindings (Java.Interop, ObjCRuntime), and framework selection. Spans 20 topic areas. Do not use for backend API design or CI/CD pipelines.
99dotnet-api
Builds ASP.NET Core APIs, EF Core data access, gRPC, SignalR, and backend services with middleware, security (OAuth, JWT, OWASP), resilience, messaging, OpenAPI, .NET Aspire, Semantic Kernel, HybridCache, YARP reverse proxy, output caching, Office documents (Excel, Word, PowerPoint), PDF, and architecture patterns. Spans 32 topic areas. Do not use for UI rendering patterns or CI/CD pipeline authoring.
90dotnet-testing
Defines .NET test strategy and implementation patterns across xUnit v3 (Facts, Theories, fixtures, IAsyncLifetime), integration testing (WebApplicationFactory, Testcontainers), Aspire testing (DistributedApplicationTestingBuilder), snapshot testing (Verify, scrubbing), Playwright E2E browser automation, BenchmarkDotNet microbenchmarks, code coverage (Coverlet), mutation testing (Stryker.NET), UI testing (page objects, selectors), and AOT WASM test compilation. Spans 13 topic areas. Do not use for production API architecture or CI workflow authoring.
86dotnet-advisor
Routes .NET/C# requests to the correct domain skill and loads coding standards as baseline for all code paths. Determines whether the task needs API, UI, testing, devops, tooling, or debugging guidance based on prompt analysis and project signals, then invokes skills in the right order. Always invoked after [skill:using-dotnet] detects .NET intent. Do not use for deep API, UI, testing, devops, tooling, or debugging implementation guidance.
60dotnet-debugging
Debugs Windows and Linux/macOS applications (native, .NET/CLR, mixed-mode) with WinDbg MCP (crash dumps, !analyze, !syncblk, !dlk, !runaway, !dumpheap, !gcroot, BSOD), dotnet-dump, lldb with SOS, createdump, and container diagnostics (Docker, Kubernetes). Hang/deadlock diagnosis, high CPU triage, memory leak investigation, kernel debugging, and dotnet-monitor for production. Spans 17 topic areas. Do not use for routine .NET SDK profiling, benchmark design, or CI test debugging.
57