Middleware in ASP.NET Core
A deep-dive walkthrough of the ASP.NET Core middleware pipeline — covering the request delegate chain model, why registration order determines execution order and short-circuiting, writing custom middleware both the conventional and IMiddleware-interface ways, the specific dependency-injection lifetime trap middleware introduces, branching the pipeline with Map/MapWhen, how the built-in exception-handling, authentication, and routing middleware actually work, and where middleware ends and endpoint routing begins.
Table of Contents
- Introduction
- The Pipeline Model: RequestDelegate and the Chain
- Order Matters: Registration Order Is Execution Order
- app.Use, app.Run, and app.Map
- Short-Circuiting the Pipeline
- Writing Custom Middleware: The Conventional Approach
- Writing Custom Middleware: The IMiddleware Interface
- The Middleware Dependency Injection Trap
- Branching the Pipeline: Map and MapWhen
- Exception-Handling Middleware in Depth
- Authentication and Authorization Middleware
- Where Middleware Ends and Endpoint Routing Begins
- Middleware vs. Filters: Two Different Extension Points
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
Every HTTP request an ASP.NET Core application handles passes through a pipeline of middleware components — small, composable pieces of code, each getting a chance to inspect or modify the request, decide whether to pass it further down the chain, and inspect or modify the response as it flows back out. Logging, exception handling, authentication, routing — all of it is middleware, including the pieces built directly into the framework, and the entire pipeline is really just a chain of delegates calling one another, in an order you control explicitly by how you register them. This guide goes deep on that chain model, the single most important and most frequently misunderstood fact about middleware (registration order is execution order, and it matters enormously), how to write your own middleware correctly — including a dependency-injection lifetime trap that's specific to middleware and genuinely easy to fall into — and how the built-in middleware for exception handling, authentication, and routing actually fits into this same model.
Request → [Exception Handling] → [Logging] → [Authentication] → [Routing] → [Your Endpoint]
↓ ↓ ↓ ↓ ↓
Response ← [Exception Handling] ← [Logging] ← [Authentication] ← [Routing] ← [Your Endpoint]
Each middleware runs code BEFORE calling the next one (the "down" arrow),
and can run code AFTER it returns (the "up" arrow) — a request flows IN,
and a response flows back OUT, through the SAME chain, in reverse.
1. The Pipeline Model: RequestDelegate and the Chain
A RequestDelegate is the fundamental unit everything else is built from
public delegate Task RequestDelegate(HttpContext context);
This series' Delegates guide covers what a delegate fundamentally is — this is just one specific delegate type, but it's the single most important type in this entire guide: every middleware, at its core, is (or produces) a RequestDelegate — something that takes an HttpContext (carrying the request and the eventual response) and asynchronously does something with it.
Middleware as a function that wraps the next middleware's delegate
// Conceptually, EVERY middleware has this shape: given the NEXT delegate in the chain,
// produce a NEW delegate that does its own work, then (usually) calls `next`
RequestDelegate BuildMiddleware(RequestDelegate next)
{
return async (HttpContext context) =>
{
// code here runs BEFORE the rest of the pipeline
await next(context); // calls the NEXT middleware in the chain
// code here runs AFTER the rest of the pipeline has finished (on the way back OUT)
};
}
This is the actual, literal shape of a middleware component — a function taking the "next" delegate and producing a new one that wraps it. Chaining several of these together, each one's next pointing to the next middleware's delegate, is precisely what builds the pipeline diagram in this guide's introduction: a nested sequence of "do something, call next, do something else" delegates, one inside another.
app.Use is how you register one of these into the chain
app.Use(async (context, next) =>
{
Console.WriteLine("Before");
await next(context); // pass control to whatever middleware comes NEXT
Console.WriteLine("After");
});
This is the most direct, inline way to add a middleware step — app.Use takes exactly the shape from the previous example (a context and a "next" delegate) and registers it into the pipeline, in the order you call app.Use, which is the entire subject of Section 2.
2. Order Matters: Registration Order Is Execution Order
The pipeline is built in EXACTLY the order you register middleware in Program.cs
var app = builder.Build();
app.Use(async (context, next) => { Console.WriteLine("Middleware A - before"); await next(context); Console.WriteLine("Middleware A - after"); });
app.Use(async (context, next) => { Console.WriteLine("Middleware B - before"); await next(context); Console.WriteLine("Middleware B - after"); });
app.Run(async context => { Console.WriteLine("Terminal middleware"); await context.Response.WriteAsync("Hello"); });
// Output for every request, in EXACTLY this order:
// Middleware A - before
// Middleware B - before
// Terminal middleware
// Middleware B - after
// Middleware A - after
This is the single most important mechanical fact in this entire guide, worth internalizing precisely: middleware registered first runs its "before" code first, and (because each middleware wraps everything after it) runs its "after" code last, once every subsequent middleware has finished — this is exactly the same nested-wrapping structure as calling several functions each surrounding a call to the next, and it produces the same "first in, last out" ordering you'd expect from that kind of nesting.
Why this makes registration order a genuine, consequential design decision
// ❌ Wrong order: authentication runs AFTER exception handling has ALREADY passed through,
// but if something in exception handling itself needed to know WHO the user is, it can't
app.UseAuthentication();
app.UseExceptionHandler("/Error");
// ✅ Correct, standard order: exception handling wraps EVERYTHING, catching failures
// from authentication itself too, not just from what comes after it
app.UseExceptionHandler("/Error");
app.UseAuthentication();
Because each middleware only sees what happens in the middleware registered after it, getting this order wrong has real, concrete consequences — exception-handling middleware registered too late in the chain won't catch exceptions thrown by anything registered before it; authentication middleware registered too late means anything running before it can't rely on the user being identified yet. This is precisely why ASP.NET Core's own project templates and documentation are opinionated and specific about the recommended order for the built-in middleware (Sections 9-10 cover exception handling and authentication specifically).
The recommended, standard ordering for common built-in middleware
app.UseExceptionHandler("/Error"); // FIRST — needs to wrap everything else to catch their exceptions
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting(); // determines WHICH endpoint will handle this request
app.UseAuthentication(); // WHO is the caller?
app.UseAuthorization(); // ARE they allowed to access the endpoint routing selected?
app.MapControllers(); // the terminal middleware — actually invokes the selected endpoint
This ordering isn't arbitrary — UseRouting() needs to run before UseAuthorization() because authorization needs to know which endpoint was matched (to check its specific authorization requirements, like a [Authorize] attribute); UseAuthentication() needs to run before UseAuthorization() for the obvious reason that you need to know who someone is before deciding what they're allowed to do. Section 11 covers exactly how UseRouting() and the later endpoint-invocation step relate.
3. app.Use, app.Run, and app.Map
app.Use: adds a middleware that CAN call the next one
app.Use(async (context, next) =>
{
// do work
await next(context); // continues the pipeline
});
app.Use is the general-purpose registration method — the middleware it registers receives the next delegate and can choose to call it (continuing the pipeline) or not (Section 4 covers exactly what happens if it doesn't).
app.Run: adds a TERMINAL middleware — there's no next at all
app.Run(async context =>
{
await context.Response.WriteAsync("This is the END of the pipeline — there's no `next` to call");
});
app.Run registers a middleware with no next parameter at all — it's meant to be the final step in the pipeline, always short-circuiting (Section 4) by definition, since there's nothing after it to continue to. Anything registered via app.Use after an app.Run call will never actually execute for requests that reach the app.Run, since the pipeline's chain simply doesn't extend past it.
app.Map: branches the pipeline based on a URL path prefix
app.Map("/admin", adminApp =>
{
adminApp.Use(async (context, next) => { /* admin-specific middleware */ await next(context); });
adminApp.Run(async context => await context.Response.WriteAsync("Admin area"));
});
app.Run(async context => await context.Response.WriteAsync("Everything else"));
app.Map creates a genuinely separate branch of the pipeline, active only for requests whose path starts with the given prefix — this is covered in full depth in Section 8, worth knowing here as the third fundamental registration method, alongside Use (continue) and Run (terminate).
4. Short-Circuiting the Pipeline
A middleware that doesn't call next stops the request from going any further
app.Use(async (context, next) =>
{
if (!context.Request.Headers.ContainsKey("X-Api-Key"))
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("Missing API key");
return; // ❌ NOT calling next(context) — the pipeline STOPS here
}
await next(context); // only reached if the check passed
});
This is a deliberate, common, and entirely legitimate pattern called short-circuiting: a middleware decides, based on some condition, that the request shouldn't proceed any further — it writes whatever response is appropriate (an error, a redirect, a cached response) and simply doesn't call next, which means every middleware registered after this one, and the eventual endpoint itself, never runs for this specific request at all.
Why this matters for understanding what "the pipeline" actually is
The pipeline isn't a fixed, guaranteed sequence every request marches
through identically — it's a chain of OPTIONAL continuations, where any
middleware can choose to stop the chain. Built-in middleware relies on
this constantly: authentication middleware short-circuits with a 401 if
no valid credentials are present; a caching middleware might short-
circuit by returning a cached response directly, never reaching the
actual endpoint logic at all.
Understanding short-circuiting is what makes sense of why order (Section 2) matters so much — if authentication middleware is going to short-circuit unauthorized requests with a 401, it needs to run early enough in the chain that nothing sensitive (later middleware, the actual endpoint) ever executes for a request that gets short-circuited.
Calling next more than once, or after already writing a response, are both genuine bugs
// ❌ Calling next() twice is a real, if unusual, bug — the DOWNSTREAM pipeline
// would run TWICE for a single request, which is almost never intended
await next(context);
await next(context); // don't do this
// ❌ Writing to the response and STILL calling next() can cause an
// "unable to start response, headers already sent" exception if
// something later in the pipeline also tries to write to the response
context.Response.StatusCode = 404;
await next(context); // risky — later middleware might also try to write
Worth knowing these specific, real anti-patterns — next is meant to be called exactly zero or one times per request, and short-circuiting (not calling it, or calling it and then returning without further writes) should be a clean, deliberate either/or decision, not something ambiguous or double-executed.
5. Writing Custom Middleware: The Conventional Approach
The convention-based pattern: a class with a specific constructor and InvokeAsync shape
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
public RequestTimingMiddleware(RequestDelegate next) => _next = next; // the NEXT delegate, captured ONCE
public async Task InvokeAsync(HttpContext context) // can ALSO be named "Invoke" — both are recognized
{
var stopwatch = Stopwatch.StartNew();
await _next(context); // continue the pipeline
stopwatch.Stop();
Console.WriteLine($"Request took {stopwatch.ElapsedMilliseconds}ms");
}
}
This is the standard, conventional way to write a reusable custom middleware as its own class rather than an inline lambda — the framework doesn't require implementing any specific interface for this pattern (it works purely by convention: a constructor accepting a RequestDelegate, and a method named Invoke or InvokeAsync accepting an HttpContext) — this is discovered and wired up via reflection when you register it.
Registering a class-based middleware with UseMiddleware<T>
app.UseMiddleware<RequestTimingMiddleware>();
UseMiddleware<T> is what actually instantiates your middleware class and threads it into the pipeline — the framework constructs one instance, passing in the next delegate (and, per Section 7, any other constructor-injectable services), and calls its InvokeAsync for every request that reaches this point in the pipeline.
Additional per-request parameters in InvokeAsync, resolved from DI automatically
public async Task InvokeAsync(HttpContext context, IOrderRepository repository) // injected PER-CALL, not in the constructor
{
// repository is resolved FRESH for THIS request, from THIS request's scope
await _next(context);
}
Section 7 explains exactly why this matters, but worth introducing the mechanic here: InvokeAsync (unlike the constructor) can accept additional parameters beyond HttpContext, and the framework resolves these from the current request's DI scope on every single call — this is a deliberate, important design detail this guide's next section covers in full depth.
6. Writing Custom Middleware: The IMiddleware Interface
An alternative, interface-based approach, with explicit DI integration
public class RequestTimingMiddleware : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next) // `next` is a PARAMETER, not captured in the constructor
{
var stopwatch = Stopwatch.StartNew();
await next(context);
stopwatch.Stop();
Console.WriteLine($"Request took {stopwatch.ElapsedMilliseconds}ms");
}
}
builder.Services.AddTransient<RequestTimingMiddleware>(); // MUST be explicitly registered in the DI container
app.UseMiddleware<RequestTimingMiddleware>();
IMiddleware is a formal interface-based alternative to Section 5's convention-based approach — the key structural difference is that next is passed as a parameter to InvokeAsync directly, rather than captured once in the constructor, and the middleware class itself must be explicitly registered with the DI container (AddTransient, typically) before UseMiddleware<T> can resolve it.
Why IMiddleware exists: it makes the middleware's own dependency-injection lifetime explicit and controllable
Because IMiddleware-based middleware is resolved from the DI container
EVERY TIME (following whatever lifetime you registered it with —
Transient, Scoped, or Singleton, per this series' ASP.NET Core
Dependency Injection guide), you have EXPLICIT control over its
lifetime — unlike Section 5's convention-based middleware, which is
ALWAYS constructed ONCE and reused for every request, regardless of
what you do.
This is the genuine, practical trade-off between the two approaches, and it's directly related to Section 7's DI trap: convention-based middleware (Section 5) is always effectively a singleton (constructed once, at application startup, and reused for every subsequent request) — IMiddleware gives you the choice to register it as Scoped or Transient instead, if that's genuinely what a specific middleware needs.
7. The Middleware Dependency Injection Trap
The critical fact: convention-based middleware is constructed ONCE, like a singleton, regardless of how it's registered
public class MyMiddleware
{
private readonly IOrderRepository _repository; // ❌ SCOPED service, injected via the CONSTRUCTOR
public MyMiddleware(RequestDelegate next, IOrderRepository repository) // constructor injection
{
_next = next;
_repository = repository; // this is the EXACT captive dependency problem from this series'
// ASP.NET Core Dependency Injection guide's Section 7 — just less obvious here
}
}
This is the single most important, and most easily missed, gotcha specific to middleware: app.UseMiddleware<T>() constructs your middleware class exactly once, at application startup — not once per request. Any dependency injected via the constructor (as opposed to InvokeAsync's parameters, per Section 5's closing example) is therefore resolved exactly once too, at startup — which means injecting a Scoped service via the constructor is precisely this series' ASP.NET Core Dependency Injection guide's captive dependency problem, just occurring implicitly through middleware's own single-construction lifetime rather than an explicit Singleton registration.
Why InvokeAsync's per-call parameters exist specifically to solve this
public class MyMiddleware
{
private readonly RequestDelegate _next; // safe — RequestDelegate itself has no per-request state issue
public MyMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context, IOrderRepository repository) // ✅ injected HERE instead
{
// `repository` is resolved FRESH, from THIS specific request's scope, on EVERY call
await _next(context);
}
}
This is exactly why Section 5 introduced InvokeAsync's additional-parameter capability — parameters on InvokeAsync (beyond HttpContext) are resolved fresh, from the current request's DI scope, on every single invocation, which is the correct, safe way to consume a Scoped (or Transient) service inside convention-based middleware. The rule this produces is simple and worth memorizing: inject Singleton services via the constructor; inject Scoped or Transient services via InvokeAsync's parameters, never the constructor.
The same trap, restated for IMiddleware-based middleware
public class MyMiddleware : IMiddleware
{
private readonly IOrderRepository _repository; // depends on HOW this class itself was registered
public MyMiddleware(IOrderRepository repository) => _repository = repository; // constructor injection here is FINE
public async Task InvokeAsync(HttpContext context, RequestDelegate next) { await next(context); }
}
builder.Services.AddScoped<MyMiddleware>(); // AS LONG AS this is registered Scoped (or Transient), constructor injection is safe
IMiddleware doesn't automatically fix this — it just gives you the tool to fix it correctly: because IMiddleware-based middleware is resolved from DI on every request (rather than constructed once at startup), constructor injection of a Scoped service is safe here, provided you registered the middleware class itself with a matching (Scoped or Transient) lifetime — registering an IMiddleware class as Singleton would reintroduce the exact same trap this section describes.
8. Branching the Pipeline: Map and MapWhen
Map: branches based on a URL path prefix, and the branch's pipeline REPLACES the main one for matching requests
app.Map("/api", apiApp =>
{
apiApp.UseMiddleware<ApiKeyAuthMiddleware>(); // ONLY runs for requests under /api
apiApp.Run(async context => await context.Response.WriteAsync("API response"));
});
app.Run(async context => await context.Response.WriteAsync("Non-API response"));
Any request whose path starts with /api is diverted entirely into the branch's own pipeline — it does not also continue through whatever was registered after the app.Map(...) call in the main pipeline; the branch is a genuinely separate, self-contained sub-pipeline. This is useful for applying entirely different middleware sets to different parts of an application (a distinct authentication scheme for an API area versus a cookie-based scheme for a web UI area, for instance).
MapWhen: branches based on an arbitrary predicate, not just a path prefix
app.MapWhen(context => context.Request.Headers.ContainsKey("X-Beta-Feature"), betaApp =>
{
betaApp.Use(async (context, next) => { /* beta-specific middleware */ await next(context); });
});
MapWhen generalizes Map's path-based branching to any condition you can express as a Func<HttpContext, bool> — a header check, a query string value, anything derivable from the request — making it the more flexible, if slightly more verbose, branching tool when the condition genuinely isn't just "does the path start with X."
Why branches rejoin (or don't) matters for understanding the pipeline's actual shape
Per this guide's Introduction diagram: the overall pipeline is a TREE, not
strictly a single linear chain, once Map/MapWhen are involved — the main
pipeline can branch into several separate sub-pipelines, each with its
own middleware, and a request only ever flows through ONE branch
(whichever one its path/condition matched), never through multiple
branches or back into the "trunk" pipeline after matching one.
Worth updating the mental model from this guide's opening diagram slightly: while the common case really is a single linear chain, Map/MapWhen genuinely turns it into a tree structure for applications that need meaningfully different pipelines for different areas — still built from exactly the same RequestDelegate-wrapping mechanism (Section 1), just organized into branches rather than one single sequence.
9. Exception-Handling Middleware in Depth
UseExceptionHandler: catches exceptions from everything registered AFTER it, and re-executes the pipeline against an error path
app.UseExceptionHandler("/Error"); // must be registered EARLY — it needs to wrap everything else (Section 2)
// A minimal API or controller action mapped to "/Error":
app.Map("/Error", errorApp => errorApp.Run(async context =>
{
var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionFeature?.Error; // the ORIGINAL exception that was caught
context.Response.StatusCode = 500;
await context.Response.WriteAsync("An error occurred.");
}));
UseExceptionHandler wraps everything registered after it in a try/catch (conceptually — the real implementation is somewhat more involved, but this is the correct mental model) — on catching an unhandled exception from downstream, it re-executes the request against the configured error path, with the original exception made available via IExceptionHandlerFeature, letting your error-handling logic build an appropriate response without needing to catch exceptions manually in every individual endpoint.
The lambda-based alternative: UseExceptionHandler with inline configuration
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new { error = "An unexpected error occurred." });
});
});
This is functionally equivalent to the path-based form above, just configured inline rather than requiring a separate mapped route — a common, convenient choice for APIs specifically, where the error response is typically a structured JSON payload rather than an HTML error page.
Why this belongs at the very top of the middleware chain, restated with the mechanism now explained
Per Section 2's general ordering guidance, now with the FULL reasoning:
because UseExceptionHandler only catches exceptions from middleware
registered AFTER it (per the chain model, Section 1), registering it
ANYWHERE other than first means exceptions from earlier middleware
(logging, HTTPS redirection, or a custom middleware registered before
it) would go entirely UNCAUGHT by this handler.
This closes the loop on Section 2's ordering guidance with the actual mechanical reason behind it — exception-handling middleware's usefulness is directly, structurally limited to whatever comes after it in the chain, which is precisely why the standard convention places it first.
10. Authentication and Authorization Middleware
UseAuthentication: determines WHO is making the request, populating HttpContext.User
app.UseAuthentication(); // examines the request (a cookie, a bearer token, etc.) and sets HttpContext.User accordingly
Authentication middleware doesn't reject anything by itself — its job is purely to identify the caller, if possible, based on whatever credentials the request carries, populating HttpContext.User with a ClaimsPrincipal representing that identity (or an unauthenticated, anonymous principal if no valid credentials were present).
UseAuthorization: determines whether the IDENTIFIED caller is allowed to access the specific endpoint being requested
app.UseAuthorization(); // checks the MATCHED ENDPOINT's [Authorize] requirements against HttpContext.User
Authorization middleware runs after both authentication (it needs to know who the caller is) and routing (Section 11 — it needs to know which specific endpoint was matched, since that's where [Authorize] attributes and their specific requirements, like role or policy requirements, actually live) — if the identified user doesn't satisfy the matched endpoint's requirements, authorization middleware short-circuits (Section 4) the request with a 401 or 403 response.
Why authentication and authorization are genuinely separate middleware, not one combined step
Authentication answers: "who is this?"
Authorization answers: "is THIS specific person allowed to do THIS specific thing?"
This separation mirrors a real, meaningful distinction — a request can be successfully authenticated (the framework knows exactly who's asking) and still be unauthorized (that specific, known person doesn't have permission for this specific action) — keeping these as two distinct middleware steps, each doing one job, is a direct application of the single-responsibility thinking this series' Threading and other guides apply elsewhere, here specifically to the request pipeline's own composition.
11. Where Middleware Ends and Endpoint Routing Begins
UseRouting: matches the request to a specific endpoint, without invoking it yet
app.UseRouting(); // examines the request's path/method, finds the MATCHING endpoint, stores it on HttpContext
UseRouting is itself just another middleware in the chain — its specific job is to look at the incoming request and determine which registered endpoint (a controller action, a minimal API route, a Razor Page) it corresponds to, storing that match on the HttpContext for later middleware (specifically UseAuthorization, per Section 10) to consult, without actually invoking that endpoint yet.
MapControllers/MapGet/etc.: registers the endpoints themselves, and the terminal middleware that actually invokes the matched one
app.MapControllers(); // registers controller-based endpoints
app.MapGet("/hello", () => "Hello, world!"); // registers a minimal API endpoint
These Map* calls (distinct from Section 8's app.Map path-branching method, despite the similar name) do two things: they register the available endpoints for UseRouting to match against, and they collectively serve as the pipeline's terminal middleware — once a request reaches this point having been matched and authorized, it's here that the actual controller action or minimal API delegate is finally invoked.
The genuine distinction: middleware is about the PIPELINE; endpoints are what the pipeline ultimately routes TO
Middleware: cross-cutting concerns applied to EVERY request passing
through a given point in the chain (logging, auth, exception handling) —
doesn't know or care about application-specific business logic.
Endpoints (controllers, minimal APIs): the actual, request-specific
business logic — "handle a GET to /orders/5," specifically.
This is worth stating as the clean conceptual boundary this whole guide has been building toward: middleware handles the concerns that apply broadly, uniformly, across many or all requests, regardless of which specific business operation they're ultimately for; endpoint routing and the endpoints themselves handle the concern that's inherently specific to this request — "what does a GET to /orders/5 actually mean, and what should happen." Everything before UseRouting/MapControllers in the pipeline is cross-cutting; everything the matched endpoint itself does is request-specific.
12. Middleware vs. Filters: Two Different Extension Points
MVC filters: a SIMILAR cross-cutting concept, but scoped specifically to controller/action execution, not the whole pipeline
public class LogActionFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context) => Console.WriteLine("Before action");
public void OnActionExecuted(ActionExecutedContext context) => Console.WriteLine("After action");
}
ASP.NET Core MVC has its own, separate extensibility mechanism — filters (IActionFilter, IExceptionFilter, IAuthorizationFilter, and others) — which look conceptually similar to middleware (before/after hooks around something) but operate at a narrower scope: specifically around the execution of a controller action, with direct access to MVC-specific context (model binding results, the action's arguments) that raw middleware, operating purely on HttpContext, doesn't have.
When to reach for middleware versus a filter
Middleware: applies to EVERY request reaching this point in the pipeline
— including ones that never reach an MVC controller at all (a static
file request, a minimal API endpoint, a request that gets short-
circuited earlier). The right choice for genuinely cross-cutting,
framework-level concerns.
Filters: apply SPECIFICALLY to MVC controller actions, with access to
MVC-specific context (action arguments, the controller instance,
model-binding/validation results) that middleware simply doesn't have
visibility into. The right choice when the cross-cutting logic
genuinely needs that MVC-specific context.
This is a genuine, practical decision point worth understanding rather than treating the two as interchangeable: if the logic needs to run for literally every request regardless of whether it's headed to an MVC controller, or needs to run at a point in the pipeline before routing has even determined an endpoint, middleware is the right tool; if the logic specifically needs to inspect or modify a controller action's bound arguments or its result, a filter is the right, more specifically-scoped tool.
13. Common Pitfalls
| Pitfall | Why it hurts | Better approach |
|---|---|---|
| Registering middleware in the wrong order | Later middleware may depend on something earlier middleware was supposed to set up (identity, the matched endpoint); exception handling won't catch what came before it | Follow the standard, documented ordering (Section 2), and understand why it's ordered that way, not just that it should be |
| Injecting a Scoped service via a convention-based middleware's CONSTRUCTOR | The middleware class is only constructed once, at startup — this is the captive dependency problem occurring implicitly | Inject Scoped/Transient dependencies via InvokeAsync's parameters instead, which are resolved fresh per request (Section 7) |
Registering an IMiddleware class as Singleton when it needs Scoped dependencies via its constructor |
Reintroduces the exact same captive dependency trap, just through a different registration path | Match the IMiddleware class's own DI registration lifetime to what its constructor dependencies actually need (Section 7) |
Forgetting to call next(context) unintentionally |
The pipeline silently stops — later middleware and the eventual endpoint never run, often producing a confusing, empty or incomplete response with no obvious error | Be deliberate about short-circuiting (Section 4) — it should be an intentional decision, never an accidental omission |
Writing to the response and then still calling next(context)
|
Risks an "headers already sent" exception if something later in the pipeline also attempts to write | Treat writing a response and calling next as mutually exclusive within a single middleware invocation |
Assuming UseExceptionHandler catches exceptions from middleware registered before it |
It only wraps what comes AFTER it in the chain — exceptions from earlier middleware go uncaught | Register exception-handling middleware first, or as close to first as genuinely possible (Section 9) |
Confusing app.Map (pipeline path-branching) with app.MapGet/MapControllers (endpoint registration) |
Despite similar names, they do genuinely different things — one branches the middleware pipeline, the others register endpoints for routing to match against | Keep the distinction clear: Map/MapWhen branch the middleware chain (Section 8); Map{Verb}/MapControllers register endpoints (Section 11) |
| Using a filter for logic that needs to apply to non-MVC requests too | Filters only run for matched MVC controller actions — a request to a minimal API endpoint or a static file never triggers them | Use middleware for genuinely cross-cutting concerns spanning the whole pipeline; reserve filters for MVC-action-specific needs (Section 12) |
Quick Reference Table
| Concept | C# Syntax | Purpose |
|---|---|---|
| Inline middleware | app.Use(async (context, next) => { ... }); |
Ad hoc middleware registered directly in Program.cs
|
| Terminal middleware | app.Run(async context => { ... }); |
Ends the pipeline; no next to call |
| Class-based, convention style | app.UseMiddleware<MyMiddleware>(); |
Reusable middleware; constructed ONCE at startup (Section 5, Section 7) |
Class-based, IMiddleware style |
builder.Services.AddScoped<MyMiddleware>(); app.UseMiddleware<MyMiddleware>(); |
Reusable middleware with explicit, controllable DI lifetime (Section 6) |
| Path-based branching | app.Map("/api", branch => { ... }); |
Diverts matching requests into a genuinely separate sub-pipeline (Section 8) |
| Predicate-based branching | app.MapWhen(ctx => ..., branch => { ... }); |
Branches on any condition, not just a path prefix |
| Exception handling | app.UseExceptionHandler("/Error"); |
Catches unhandled exceptions from everything registered after it (Section 9) |
| Identify the caller | app.UseAuthentication(); |
Populates HttpContext.User, without itself rejecting anything |
| Enforce access rules | app.UseAuthorization(); |
Checks the matched endpoint's requirements against the identified caller |
| Match the request to an endpoint |
app.UseRouting(); + app.MapControllers();
|
Determines, then later invokes, the specific business logic this request maps to |
Conclusion
Middleware's entire model boils down to one simple, mechanical idea — a chain of delegates, each wrapping the next, executed in exactly the order you register them — but that simplicity is precisely what makes registration order, short-circuiting, and the middleware-specific dependency-injection trap so consequential once you're building anything beyond a trivial pipeline. Understanding that convention-based middleware is constructed exactly once, effectively behaving like a singleton regardless of what you inject into its constructor, is the single most important, most specific piece of knowledge this guide covers — it's the same captive dependency problem this series' ASP.NET Core Dependency Injection guide details, just showing up implicitly through middleware's own construction lifetime rather than an explicit AddSingleton call, which is exactly what makes it easy to introduce without realizing it.
Everything else — Map/MapWhen branching the pipeline into a tree, exception handling needing to sit first to wrap everything else, authentication and authorization as two deliberately separate concerns, and the clean boundary between middleware's cross-cutting role and an endpoint's request-specific business logic — builds on that same chain-of-delegates foundation. Knowing exactly what a RequestDelegate is, and that "the pipeline" is really just nested function calls with an explicit next, is what turns Program.cs's sequence of app.Use... calls from configuration you copy from a template into something you can genuinely reason about and extend correctly.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the middleware-constructor-captured-a-stale-DbContext-at-startup debugging session that made the "middleware is basically a singleton" rule click far better than any documentation note ever could.
Top comments (0)