Why middleware mastery matters for .NET interviews
The middleware pipeline is the backbone of every ASP.NET Core application. Interviewers test it because a wrong ordering can silently break authentication, expose APIs to CORS attacks, or swallow exceptions without logging. This article explains how it works, why ordering matters, and how to write your own.
What middleware actually is
Middleware are delegates composed into a pipeline. Each component receives an HttpContext
and a RequestDelegate (next). Calling next passes control to the subsequent component.
Not calling it short-circuits the pipeline.
// The simplest possible middleware — wraps everything:
app.Use(async (context, next) =>
{
// → request path: runs before next middleware
Console.WriteLine($"→ {context.Request.Path}");
await next(context); // pass to the next component
// ← response path: runs after all subsequent middleware complete
Console.WriteLine($"← {context.Response.StatusCode}");
});
The pipeline is an onion — the request travels inward through each layer, the response travels outward in reverse. The first middleware registered has the outermost shell.
Use, Run, and Map — the three primitives
// Use — middleware that continues:
app.Use(async (ctx, next) =>
{
ctx.Items["start"] = DateTime.UtcNow; // add context for later middleware
await next(ctx);
var elapsed = DateTime.UtcNow - (DateTime)ctx.Items["start"]!;
ctx.Response.Headers["X-Elapsed-Ms"] = elapsed.TotalMilliseconds.ToString("F0");
});
// Run — terminal middleware (never calls next):
app.Run(async ctx =>
{
await ctx.Response.WriteAsync("Hello from terminal middleware");
// Calling next here would do nothing useful — there is no next
});
// Map — branch the pipeline for a path prefix:
app.Map("/health", healthApp =>
{
healthApp.Run(async ctx =>
await ctx.Response.WriteAsync("OK")); // only /health requests reach here
});
// MapWhen — branch on any predicate:
app.MapWhen(
ctx => ctx.Request.Method == "OPTIONS",
corsApp => corsApp.Run(async ctx =>
{
ctx.Response.StatusCode = 204;
// Handle CORS preflight manually
}));
Ordering — the most common source of bugs
Wrong ordering causes real problems. Here is the Microsoft-recommended ordering with reasoning:
// 1. Exception handler — OUTERMOST: catches exceptions from everything below
app.UseExceptionHandler("/error");
// 2. HSTS / HTTPS redirect — security headers before any content
app.UseHsts();
app.UseHttpsRedirection();
// 3. Static files — served WITHOUT authentication (by design)
// Do not put auth before this unless static assets are private
app.UseStaticFiles();
// 4. Routing — MUST come before CORS and auth so they can read endpoint metadata
app.UseRouting();
// 5. CORS — MUST be after UseRouting (reads endpoint CORS policies)
// MUST be before UseAuthentication
app.UseCors("MyPolicy");
// 6. Auth — MUST be after routing (needs matched endpoint to check [Authorize])
app.UseAuthentication();
app.UseAuthorization();
// 7. Custom business middleware (e.g., rate limiting, tenant resolution)
app.UseRateLimiter();
// 8. Endpoint dispatch — INNERMOST: runs the actual controller action or handler
app.MapControllers();
Common ordering bugs and their symptoms:
| Bug | Symptom |
|---|---|
UseCors before UseRouting | Per-endpoint CORS policies ignored; headers always apply the global policy |
UseAuthentication before UseRouting | Auth runs for every path including static files and health checks |
UseStaticFiles after auth | Every CSS/JS/image request requires authentication |
UseExceptionHandler not first | Exceptions from authentication or CORS middleware crash with no error page |
Short-circuiting the pipeline
A middleware that returns without calling next terminates processing — no subsequent
middleware or controller runs:
app.Use(async (context, next) =>
{
// Rate limit check:
if (!await _rateLimiter.TryConsumeAsync(context.Connection.RemoteIpAddress))
{
context.Response.StatusCode = 429; // Too Many Requests
context.Response.Headers.RetryAfter = "60";
await context.Response.WriteAsync("Rate limit exceeded");
return; // ← short-circuit; next is never called
}
await next(context); // within limit — continue
});
Every built-in auth/authorization middleware short-circuits on failure — that's how a 401 or 403 is returned before the controller ever runs.
Writing a custom middleware class
Inline lambdas work for simple cases but become unwieldy. A class is cleaner and testable:
// Convention-based middleware (singleton instantiation):
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
// Constructor: only singleton deps here
public RequestTimingMiddleware(RequestDelegate next,
ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
// InvokeAsync: scoped/transient deps injected as parameters
public async Task InvokeAsync(HttpContext context, IMetricsService metrics)
{
var sw = Stopwatch.StartNew();
await _next(context);
sw.Stop();
_logger.LogInformation("{Method} {Path} → {StatusCode} in {Ms}ms",
context.Request.Method,
context.Request.Path,
context.Response.StatusCode,
sw.ElapsedMilliseconds);
metrics.Record(context.Request.Path, sw.ElapsedMilliseconds);
}
}
// Extension method — conventional registration:
public static class RequestTimingExtensions
{
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app)
=> app.UseMiddleware<RequestTimingMiddleware>();
}
// Program.cs:
app.UseRequestTiming();
The IMiddleware interface is the alternative: the class is resolved from DI per request,
so scoped dependencies can be injected directly into the constructor:
public class TenantMiddleware : IMiddleware
{
private readonly ITenantResolver _resolver; // scoped — works here
public TenantMiddleware(ITenantResolver resolver) => _resolver = resolver;
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
context.Items["Tenant"] = await _resolver.ResolveAsync(context);
await next(context);
}
}
// Must register in DI (unlike convention-based):
builder.Services.AddScoped<TenantMiddleware>();
app.UseMiddleware<TenantMiddleware>();
Exception-handling middleware
UseExceptionHandler re-executes a different endpoint when an unhandled exception is thrown:
// Production:
app.UseExceptionHandler("/error");
app.Map("/error", (HttpContext ctx) =>
{
var feature = ctx.Features.Get<IExceptionHandlerFeature>();
var ex = feature?.Error;
return ex switch
{
NotFoundException => Results.Problem(statusCode: 404, title: "Not found"),
ValidationException v => Results.ValidationProblem(v.Errors),
_ => Results.Problem(statusCode: 500, title: "Internal server error")
};
});
// Development:
if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage(); // full stack trace in browser
Always register exception handling first — if another middleware (e.g., auth) throws, the exception handler must be the outermost layer to catch it.
Built-in middleware cheat sheet
| Middleware | Method | Purpose |
|---|---|---|
| Exception handling | UseExceptionHandler / UseDeveloperExceptionPage | Catch unhandled exceptions |
| HSTS | UseHsts | Strict-Transport-Security header |
| HTTPS redirect | UseHttpsRedirection | Force HTTPS |
| Static files | UseStaticFiles | Serve wwwroot assets |
| Routing | UseRouting | Match requests to endpoints |
| CORS | UseCors | Cross-origin headers |
| Authentication | UseAuthentication | Identify user |
| Authorization | UseAuthorization | Enforce policies |
| Rate limiting | UseRateLimiter | Throttle requests (.NET 7+) |
| Output cache | UseOutputCache | Server-side caching (.NET 7+) |
| Response compression | UseResponseCompression | Gzip/Brotli |
| Session | UseSession | Session state |
Middleware vs filters — which to use?
Use middleware for cross-cutting concerns that apply to all HTTP traffic — logging, auth, CORS, rate limiting, compression. It has no knowledge of controllers or model binding.
Use action filters when you need MVC context — access to model state, action arguments, or controller metadata. Filters only run for matched MVC endpoints.
A good rule of thumb: if the concern needs to apply to health checks, static files, or
non-MVC routes, use middleware. If it needs ModelState or action arguments, use a filter.
Recap
The middleware pipeline is a chain of delegates processed in registration order (request path)
and reverse order (response path). Use wraps, Run terminates, Map branches. Ordering
is critical: exception handling outermost, static files before auth, routing before CORS and
auth, endpoint dispatch innermost. Short-circuit by not calling next. Use convention-based
middleware for singleton deps in the constructor; use IMiddleware when you need scoped deps.
Always register exception handling before anything else.