ion. The following steps outline a production-grade architecture.
Step 1: Choose the Correct Middleware Contract
ASP.NET Core provides two registration models:
- Convention-based:
public class MyMiddleware { public MyMiddleware(RequestDelegate next); public async Task Invoke(HttpContext context); }
- Factory-based (
IMiddleware): public class MyMiddleware : IMiddleware { public async Task InvokeAsync(HttpContext context); }
Use IMiddleware when the middleware requires scoped or transient dependencies. Convention-based middleware is activated at application startup, making it unsuitable for scoped services. Factory-based middleware resolves dependencies per request, aligning with DI scope lifetimes.
public sealed class RequestMetricsMiddleware : IMiddleware
{
private readonly DiagnosticSource _diagnostics;
private readonly ILogger<RequestMetricsMiddleware> _logger;
public RequestMetricsMiddleware(DiagnosticSource diagnostics, ILogger<RequestMetricsMiddleware> logger)
{
_diagnostics = diagnostics;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var sw = Stopwatch.GetTimestamp();
try
{
await context.Response.WriteAsync("placeholder"); // Replace with actual pipeline continuation
_diagnostics.Write("RequestCompleted", new { Path = context.Request.Path, Duration = Stopwatch.GetElapsedTime(sw) });
}
catch (Exception ex)
{
_logger.LogError(ex, "Pipeline failure at {Path}", context.Request.Path);
throw;
}
}
}
Step 2: Implement Pipeline Branching
Branching isolates execution paths based on route, method, or header. Use Map for exact path matching and MapWhen for predicate-based routing. Branching prevents unnecessary middleware execution and enables independent short-circuiting.
// In Program.cs
app.Map("/health", healthApp =>
{
healthApp.UseRouting();
healthApp.Run(async ctx => await ctx.Response.WriteAsync("OK"));
});
app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), apiApp =>
{
apiApp.UseAuthentication();
apiApp.UseAuthorization();
apiApp.UseRouting();
apiApp.UseEndpoints(endpoints => endpoints.MapControllers());
});
Branching reduces pipeline depth for non-API requests by 60–80%, directly lowering allocation and thread context switches.
Step 3: Apply Short-Circuiting Patterns
Short-circuiting terminates the pipeline early when downstream processing is unnecessary. Common use cases: static files, health checks, preflight CORS, and cached responses. Implement short-circuiting by returning Task.CompletedTask or calling context.Response.CompleteAsync() without invoking next.
public sealed class CacheShortCircuitMiddleware : IMiddleware
{
private readonly IMemoryCache _cache;
public CacheShortCircuitMiddleware(IMemoryCache cache) => _cache = cache;
public async Task InvokeAsync(HttpContext context)
{
var cacheKey = context.Request.Path.Value;
if (_cache.TryGetValue(cacheKey, out CachedResponse? cached) && cached is not null)
{
context.Response.StatusCode = cached.StatusCode;
context.Response.ContentType = cached.ContentType;
await context.Response.WriteAsync(cached.Body);
return; // Short-circuit
}
await context.Response.WriteAsync("placeholder"); // Continue pipeline
}
}
Step 4: Enforce DI Scope Validation
ASP.NET Core validates DI scopes by default in development. In production, disable ValidateScopes only after verifying no captive dependencies exist. Use IServiceProviderIsService or explicit scope factories when middleware must resolve services dynamically.
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true; // Keep enabled in staging; disable in prod only after audit
});
Architecture Rationale
- IMiddleware over convention: Guarantees per-request DI resolution, preventing scope leakage and captive dependencies.
- Branching over monolithic pipelines: Reduces execution graph width, isolates failure domains, and enables route-specific middleware stacks.
- Short-circuiting over conditional logic: Eliminates downstream delegate invocations, reducing stack depth and allocation.
- DiagnosticSource over Console/Debug: Provides zero-allocation event publishing compatible with OpenTelemetry, Application Insights, and custom telemetry pipelines.
Pitfall Guide
-
Synchronous blocking in async middleware
Calling .Result or .Wait() on async operations inside Invoke blocks the thread pool. ASP.NET Core expects non-blocking I/O. Use await consistently. Blocking causes thread starvation under load, manifesting as request queue saturation.
-
Pipeline ordering violations
Middleware executes in registration order. Placing UseAuthentication() before UseRouting() forces auth evaluation on every request, including static files and health checks. Correct order: Routing → Auth → Authorization → CORS → Endpoints. Violations cause 401/403 responses on public routes and unnecessary token validation overhead.
-
Closure capture in inline delegates
Inline app.Use(async (ctx, next) => { ... }) captures variables from the enclosing scope. If those variables hold scoped services or large objects, they become captive dependencies, surviving beyond the request lifecycle. Use IMiddleware or explicit factory registration to avoid closure-induced memory leaks.
-
Overusing app.Use() for cross-cutting concerns
Logging, metrics, and error handling are frequently stacked as sequential Use() calls. This creates a deep execution graph where each layer adds allocation and context switching. Consolidate cross-cutting concerns into a single middleware or use DiagnosticSource events to decouple observation from execution.
-
Ignoring HttpContext.Items lifecycle
Items is request-scoped but mutable. Multiple middleware layers writing to the same key causes unpredictable state. Enforce key namespacing ("MyApp.Middleware.CacheHit") and treat Items as a contract, not a shared bag. Unscoped writes lead to data corruption in concurrent requests.
-
Missing exception boundary placement
Exceptions bubble up the pipeline until caught. If no middleware implements a try/catch boundary, unhandled exceptions terminate the request with a 500 and no structured logging. Place a dedicated error-handling middleware early in the pipeline, but after routing, to capture route-specific context.
-
Best practices from production experience
- Keep middleware stateless; store per-request data in
HttpContext.Items or DI-scoped services.
- Validate pipeline graph at startup using
IApplicationBuilder.ApplicationServices.GetRequiredService<IHostEnvironment>() to log execution order.
- Use
DiagnosticSource for metrics; avoid Stopwatch in hot paths.
- Test middleware in isolation with
Microsoft.AspNetCore.TestHost and mock HttpContext.
- Audit pipeline depth quarterly; targets should stay under 8 layers for API routes.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-throughput public API | Branched + Short-Circuit + IMiddleware | Minimizes pipeline depth, reduces allocation per request, isolates auth to API routes | Lower infra cost, higher sustained RPS |
| Mixed MVC + API application | Convention-based for static, IMiddleware for API, Map branching | MVC requires different middleware stack; branching prevents API auth from running on Razor pages | Moderate DI overhead, cleaner separation |
| Health/Static-heavy workload | Map isolation + terminal middleware | Eliminates downstream processing for non-business requests, reduces thread context switches | Minimal compute cost, faster cold starts |
| Compliance/audit logging required | DiagnosticSource + single logging middleware | Zero-allocation event publishing, avoids deep pipeline stacking, integrates with SIEM | Slight storage cost, negligible runtime impact |
Configuration Template
// Program.cs - Production-Grade Middleware Pipeline
var builder = WebApplication.CreateBuilder(args);
// DI Registration
builder.Services.AddSingleton<DiagnosticSource>(new DiagnosticListener("MyApp.Pipeline"));
builder.Services.AddScoped<RequestMetricsMiddleware>();
builder.Services.AddScoped<CacheShortCircuitMiddleware>();
builder.Services.AddScoped<ErrorBoundaryMiddleware>();
var app = builder.Build();
// 1. Error boundary (early, after routing context available)
app.UseMiddleware<ErrorBoundaryMiddleware>();
// 2. Static & Health (short-circuit, isolated)
app.Map("/health", health => health.Run(async ctx => await ctx.Response.WriteAsync("OK")));
app.UseStaticFiles();
// 3. API Branch (auth, routing, endpoints)
app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), api =>
{
api.UseRouting();
api.UseAuthentication();
api.UseAuthorization();
api.UseMiddleware<CacheShortCircuitMiddleware>();
api.UseMiddleware<RequestMetricsMiddleware>();
api.UseEndpoints(endpoints => endpoints.MapControllers());
});
// 4. Fallback
app.Run(async ctx =>
{
ctx.Response.StatusCode = 404;
await ctx.Response.WriteAsync("Not Found");
});
app.Run();
Quick Start Guide
- Register middleware as
IMiddleware: Replace convention-based classes with IMiddleware implementation. Register in IServiceCollection with appropriate lifetime (AddScoped for request-scoped dependencies).
- Branch non-API routes: Add
app.Map("/health", ...) and app.UseStaticFiles() before the main pipeline. Use MapWhen for route predicates.
- Insert short-circuit logic: In cache, health, or preflight middleware, return early without calling
next. Ensure response headers and status codes are set before returning.
- Validate with
DiagnosticSource: Replace Console.WriteLine or ILogger in hot paths with _diagnostics.Write("EventName", payload). Connect to OpenTelemetry or Application Insights for production telemetry.
- Test pipeline isolation: Use
Microsoft.AspNetCore.TestHost to send requests to /health, /api/v1/resource, and static paths. Verify that auth middleware does not execute on health checks, and that cache short-circuits bypass downstream layers.
Middleware patterns are not decorative; they are execution contracts. Branch early, short-circuit aggressively, inject deterministically, and measure continuously. Production .NET applications that treat the pipeline as a graph rather than a list consistently achieve higher throughput, lower allocation, and predictable fault isolation.