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/await at 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:

  1. Order Matters β€” Middleware runs in the order you register it in Program.cs. Put authentication BEFORE your endpoints. Put error handling FIRST.
  2. 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”)
  3. 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
}
  • RequestDelegate is 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:

PropertyWhat It Holds
context.RequestMethod (GET/POST), Path, Headers, Body, QueryString
context.ResponseStatusCode, Headers, Body (write-only stream)
context.ItemsPer-request dictionary (store data to share between middleware)
context.UserThe authenticated user (set by auth middleware)
context.RequestServicesScoped 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

GotchaWhy It HappensFix
Order is wrongAuth runs AFTER your controllerRegister auth middleware early
Response already sentYou write to context.Response then call next()Never write before next() unless short-circuiting
Can’t read response bodyBy default, response body is forward-onlyEnable context.Request.EnableBuffering()
Scoped service in singleton middlewareMiddleware is singleton by defaultInject scoped services in InvokeAsync parameters
Forgetting await next()The pipeline stops silentlyAlways call await _next(context) unless short-circuiting

πŸ”¬ Middleware vs Filters vs Endpoints

FeatureMiddlewareAction FilterMinimal API Endpoint
ScopeEvery requestSpecific controller/actionSpecific route
Access toRaw HttpContextActionExecutingContextHttpContext
Short-circuitβœ… Yesβœ… YesN/A (it’s the end)
DI injectionConstructorConstructor or parameterMethod parameter
Use caseLogging, auth, CORSValidation, model bindingBusiness 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:

  1. Logs every incoming request: {Method} {Path}
  2. Generates a unique Request ID (8-char hex)
  3. Stores the Request ID in HttpContext.Items["RequestId"]
  4. Logs the response: {StatusCode} ({duration}ms)
  5. Adds X-Request-Id as a response header
  6. 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-api repo when done
  • βœ… Show Jeri for review

Related: