ASP.NET Core Middleware Pipeline β Detailed Document
Learning Context: Part of Phase 1 (Foundation β .NET Internals) in the Senior .NET Engineer Learning Path 2026.
Covers these Phase 1 checklist items:
- β¬ .NET Internals β Middleware pipeline, DI container lifetimes,
async/awaitat IL level- β¬ ASP.NET Core Pipeline β Custom middleware, minimal API vs controllers tradeoffs
π§Έ Baby Explanation: The Burger Restaurant
Imagine your API is a burger restaurant π.
When a customer (HTTP request) walks in, they go through a series of stations:
Customer walks in
β πͺ Door guy: "Do you have a reservation?" (Authentication)
β π Menu guy: "What do you want to eat?" (Routing)
β π³ Kitchen: *makes the burger* (Your API logic)
β π§Ύ Cashier: "Here's your receipt" (Response)
β Customer walks out β
Each station is a piece of middleware. The request passes through them one by one, then the response passes back through them in reverse order.
π The Pipeline Flow
REQUEST IN βββ
[M1 Logger] β [M2 Auth] β [M3 Static Files] β YOUR API CODE
|
βββ RESPONSE OUT |
[M1 Logger] β [M2 Auth] β [M3 Static Files] βββββββββ
Key Rules:
- Order Matters β Middleware runs in the order you register it in
Program.cs. Put authentication BEFORE your endpoints. Put error handling FIRST. - Pass Through or Short-Circuit β Each middleware can either:
- Call
await next()β pass the request to the next middleware - Return a response β stop the pipeline right there (e.g., β401 Unauthorizedβ)
- Call
- Reverse Return β The response bubbles back through the same middleware in reverse order. After
next()returns, the code runs βon the way out.β
βοΈ How a Middleware Is Built
Every middleware has two moments:
public async Task InvokeAsync(HttpContext context)
{
// π’ BEFORE next() β Do something with the INCOMING request
await _next(context);
// π΄ AFTER next() β Do something with the OUTGOING response
}The Constructor
private readonly RequestDelegate _next;
public MyMiddleware(RequestDelegate next)
{
_next = next; // This delegate points to the NEXT middleware in the pipeline
}RequestDelegateis a delegate (function pointer) that represents the next middleware.- The DI container injects it automatically β you donβt create it yourself.
- Any other dependencies (ILogger, DbContext, etc.) are injected alongside it.
HttpContext β The Request/Response Object
Everything flows through HttpContext:
| Property | What It Holds |
|---|---|
context.Request | Method (GET/POST), Path, Headers, Body, QueryString |
context.Response | StatusCode, Headers, Body (write-only stream) |
context.Items | Per-request dictionary (store data to share between middleware) |
context.User | The authenticated user (set by auth middleware) |
context.RequestServices | Scoped IServiceProvider for this request |
π― Full Example: RequestTimingMiddleware
File: Middleware/RequestTimingMiddleware.cs
using System.Diagnostics;
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)
{
// π’ BEFORE: Request is coming IN
var stopwatch = Stopwatch.StartNew();
var requestId = Guid.NewGuid().ToString("N")[..8];
// Store in Items so downstream code can access the request ID
context.Items["RequestId"] = requestId;
_logger.LogInformation(
"[{RequestId}] IN β {Method} {Path}",
requestId,
context.Request.Method,
context.Request.Path);
// β© Pass to the NEXT middleware in the pipeline
await _next(context);
// π΄ AFTER: Response is going BACK out
stopwatch.Stop();
// Add the request ID to the response header
context.Response.Headers["X-Request-Id"] = requestId;
_logger.LogInformation(
"[{RequestId}] β OUT {StatusCode} ({Elapsed}ms)",
requestId,
context.Response.StatusCode,
stopwatch.ElapsedMilliseconds);
}
}Register in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
// β οΈ Register FIRST so it wraps all other middleware
app.UseMiddleware<RequestTimingMiddleware>();
// These run INSIDE the timing middleware
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();What Happens During a Request:
1. Request arrives: GET /api/orders
2. RequestTimingMiddleware.InvokeAsync() starts
β Stopwatch starts, RequestId generated
β Logs: "[3f8a1b2c] IN β GET /api/orders"
3. await _next(context) passes to UseAuthentication
4. UseAuthentication runs, sets context.User
5. await next() passes to UseAuthorization
6. UseAuthorization checks claims
7. await next() passes to MapControllers
8. Controller action runs: OrdersController.GetAll()
9. Response: 200 OK with JSON body
10. Bubbles BACK through middleware:
β UseAuthorization (nothing to do on way out)
β UseAuthentication (nothing to do on way out)
β Back to RequestTimingMiddleware
11. Stopwatch stops: 45ms
12. Logs: "[3f8a1b2c] β OUT 200 (45ms)"
13. X-Request-Id header added to response
14. Response sent to client
Log Output:
[3f8a1b2c] IN β GET /api/orders
[3f8a1b2c] β OUT 200 (45ms)
π¦ Extension Method (Cleaner Registration)
Instead of app.UseMiddleware<RequestTimingMiddleware>(), you can create an extension method:
public static class RequestTimingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder builder)
{
return builder.UseMiddleware<RequestTimingMiddleware>();
}
}Then in Program.cs:
app.UseRequestTiming(); // Much cleaner!β οΈ Common Gotchas
| Gotcha | Why It Happens | Fix |
|---|---|---|
| Order is wrong | Auth runs AFTER your controller | Register auth middleware early |
| Response already sent | You write to context.Response then call next() | Never write before next() unless short-circuiting |
| Canβt read response body | By default, response body is forward-only | Enable context.Request.EnableBuffering() |
| Scoped service in singleton middleware | Middleware is singleton by default | Inject scoped services in InvokeAsync parameters |
Forgetting await next() | The pipeline stops silently | Always call await _next(context) unless short-circuiting |
π¬ Middleware vs Filters vs Endpoints
| Feature | Middleware | Action Filter | Minimal API Endpoint |
|---|---|---|---|
| Scope | Every request | Specific controller/action | Specific route |
| Access to | Raw HttpContext | ActionExecutingContext | HttpContext |
| Short-circuit | β Yes | β Yes | N/A (itβs the end) |
| DI injection | Constructor | Constructor or parameter | Method parameter |
| Use case | Logging, auth, CORS | Validation, model binding | Business logic |
Rule of thumb: Use middleware for cross-cutting concerns (logging, auth, error handling). Use filters for controller-specific logic. Use endpoints for business logic.
π§ͺ Your Build Challenge
Go to your POS API and build a middleware that:
- Logs every incoming request:
{Method} {Path} - Generates a unique Request ID (8-char hex)
- Stores the Request ID in
HttpContext.Items["RequestId"] - Logs the response:
{StatusCode} ({duration}ms) - Adds
X-Request-Idas a response header - Creates a clean extension method for registration
Bonus (push yourself):
7. Add X-Response-Time header (in ms)
8. Make it skip logging for /health endpoints
Rules:
- β Donβt ask Jeri for code
- β Use this document as reference
- β
Push to
pos-apirepo when done - β Show Jeri for review
Related:
- Senior .NET Engineer Learning Path 2026 β Phase 1: Foundation