Taming Cross-Cutting Concerns with ASP.NET Core Middleware
Stop repeating boilerplate in your controllers. Learn how to use ASP.NET Core middleware to handle cross-cutting concerns like request timing and global logging efficiently.
12 Jul 2025, 13:14 UTC

The Problem: Logic Leakage
When building a web API, you often need to perform the same task for every single request: logging execution time, validating API keys, or handling global exceptions. The intuitive approach is to put this logic inside your controllers. However, this leads to "logic leakage," where your business endpoints are cluttered with boilerplate code that has nothing to do with the actual feature being delivered.
The solution is the Middleware Pipeline. Middleware are components that assemble into a request pipeline to handle requests and responses. Instead of repeating code in every action, you wrap the entire application in a series of layers that process the request on the way in and the response on the way out.
How the Pipeline Operates
ASP.NET Core uses a bidirectional pipeline. Each piece of middleware receives an HttpContext (containing the request and response) and a RequestDelegate (a reference to the next piece of middleware in the chain).
The sequence is critical. Middleware added first in the Program.cs file is the first to see the request and the last to see the response. This "onion" structure allows you to wrap inner logic with outer protections. For example, placing an exception handler at the very start of the pipeline ensures it can catch any error thrown by any subsequent middleware or controller.
Implementing a Request Timing Middleware
To move performance monitoring out of the controllers, you can create a custom middleware class. This example captures the duration of every request and appends it to the response header.
The Middleware Class
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 watch = System.Diagnostics.Stopwatch.StartNew();
// Pass the request to the next component in the pipeline
await _next(context);
watch.Stop();
var elapsedMs = watch.ElapsedMilliseconds;
// Add the timing to the response header
context.Response.Headers.Append("X-Response-Time-ms", elapsedMs.ToString());
_logger.LogInformation("Request {Method} {Path} took {Elapsed}ms",
context.Request.Method, context.Request.Path, elapsedMs);
}
}
Registering the Middleware
In your Program.cs, register the middleware before the routing and endpoint mapping. This ensures the timer starts as early as possible.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Run this early in the pipeline
app.UseMiddleware<RequestTimingMiddleware>();
app.MapGet("/", () => "Hello World");
app.Run();
Execution and Verification
To verify the middleware is functioning, run the application and use a tool like curl to inspect the headers:
# Run on your local terminal
curl -I http://localhost:5000/
Expected Result: You should see X-Response-Time-ms: [number] in the HTTP response headers and a corresponding log entry in your application console.
Trade-offs and Limitations
While middleware is powerful, it introduces specific risks:
- Latency Accumulation: Every single request must pass through every piece of middleware. Adding dozens of layers, especially those performing synchronous I/O, can significantly increase the time-to-first-byte (TTFB).
- Thread Pool Exhaustion: Middleware must be asynchronous. Using
.Resultor.Wait()insideInvokeAsynccan lead to thread pool starvation, causing the application to hang under load. - Ordering Bugs: If you place
UseRouting()after your custom middleware, the middleware will not have access to endpoint metadata (like route parameters), which can break logic that depends on knowing which controller is being called.
Actionable Summary
Use middleware when a requirement applies to the entire request regardless of the destination endpoint. For logic that only applies to specific controllers, use Action Filters instead to avoid unnecessary overhead for the rest of your application. When implementing middleware, always prioritize async/await and place global error handling at the very top of your Program.cs sequence.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.