Middleware
By the end of this lesson you'll understand how an ASP.NET Core request flows through the middleware pipeline — why ordering matters, how a step can let a request pass or short-circuit it, and how to write your own middleware, filters, and attributes for cross-cutting concerns like logging, auth, and error handling.
Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn
💡 Real-World Analogy
The middleware pipeline is like the airport security checkpoints a passenger passes through: ticket check, then bag scan, then passport control, then the gate. Each checkpoint can wave you through, modify you (make you take your belt off), or stop you completely. The order is fixed — you can't reach the gate before passport control. And on the way back out you pass the same posts in reverse. An ASP.NET request flows through middleware exactly like that: in through each step, to the handler at the gate, then back out through each step in reverse.
What "middleware" actually is
A cross-cutting concern is a job that applies to lots of requests rather than to one feature — logging, authentication, compression, error handling, CORS. You don't want to copy that code into every controller. Middleware is where it lives: a chain of small components that each get a turn at the request before (and after) your handler runs.
Each component is handed next — a delegate representing the rest of the pipeline. The component runs some code, optionally calls next to pass the request along, and then runs more code once the response comes back. Calling next makes it a passthrough ; not calling it short-circuits the request.
- 🧱 Middleware — runs for every request (logging, auth, errors, CORS)
- 🎯 Filters — run only around MVC actions (model validation, audit)
- 🏷️ Attributes — filter logic attached declaratively with [BracketSyntax]
ASP.NET can't run in this in-browser sandbox, so the ASP.NET examples below are read-only with their console output shown in // ✅ Expected output comments. The 🎯 Your Turn exercises model the exact same pipeline idea in plain C# that does run here, so you can feel the in/out flow for yourself.
1. The Pipeline & Why Order Matters
A request flows down through each middleware in the order you register them, hits the terminal handler, then flows back up in reverse. The code before await next() runs on the way in; the code after it runs on the way out. That nesting is why a step registered first wraps everything after it — so error-handling and logging go first, and authentication goes before anything that needs to know who the user is. Read this worked example and trace the output.
2. Use, Run, Map & Short-Circuiting
There are a few verbs for building the pipeline. app.Use adds a passthrough step that receives next and should call it. app.Run adds a terminal step with no next — it ends the pipeline. app.Map (and MapWhen / UseWhen ) branches the pipeline based on the path or a predicate. And any step can short-circuit : by simply not calling next (e.g. returning a 403), it stops the request before it reaches the handler.
3. Build a Pipeline Yourself
Middleware is really just a chain of responsibility : each step holds a reference to the next and decides whether to call it. You can model that in plain C# with delegates — and unlike the ASP.NET examples, this code runs right here. Fill in the three ___ blanks to wire a Logging step around an Auth step around the handler, then run it and watch the in/out order.
Now make a step short-circuit . The gate below checks for an API key; when it's missing it should print the blocked message and stop — without calling the handler. Fill in the one blank so the request never reaches the handler.
4. Custom Middleware, Filters & Attributes
For anything beyond a one-liner, write a middleware class : its constructor captures next (a RequestDelegate ) and its InvokeAsync(HttpContext) runs per request. Filters are more targeted — they run only around MVC controller actions, so use them for concerns scoped to controllers (model validation, auditing) rather than every static file or health check. A custom attribute is filter logic you attach declaratively, like [RequireApiKey] , keeping security out of your business code.
🔎 Deep Dive: middleware or filter?
Middleware sees every request, including static files, health checks, and requests that never reach a controller. It only knows about the raw HttpContext — paths, headers, status codes. Reach for it for truly global concerns: logging, error handling, CORS, compression, authentication.
Filters run inside MVC, after routing has chosen an action, so they know the controller, the action, and the bound model arguments. That makes them perfect for things like model validation or auditing a specific endpoint — work that only makes sense once you know which action is about to run.
Rule of thumb: if it should happen for requests that may never hit a controller, it's middleware. If it depends on the action or its model, it's a filter. In Minimal APIs, the equivalent of an action filter is IEndpointFilter , which supports DI and async cleanly.
Pro Tips
- 💡 Register exception handling first so its try/catch wraps every later step — a step registered after it can't catch errors that happened before it.
- 💡 Always call await next() in passthrough middleware unless you deliberately mean to short-circuit. Forgetting it silently stalls the request.
- 💡 Don't inject scoped services into a middleware constructor — the middleware is a singleton. Accept them as parameters on InvokeAsync instead.
- 💡 Use Map / MapWhen to branch (health checks, websockets) so unrelated requests skip the main pipeline entirely.
- 💡 Prefer a middleware class over an inline app.Use lambda once there's real logic — it's testable, reusable, and supports DI.
Common Errors (and the fix)
- Wrong order — auth before routing, or errors not wrapped: a step only protects what comes after it. Put exception handling and logging first, then UseAuthentication before UseAuthorization , and MapControllers last.
- Forgetting to call next() : the request hangs or silently returns an empty 200 — nothing downstream ever runs. Always await next() in passthrough middleware unless you mean to short-circuit.
- Terminal vs passthrough mix-up: app.Run ends the pipeline, so anything registered after it never executes. Use app.Use when you want later steps to keep running.
- Exceptions not handled early: if your error-handling middleware is registered after the code that throws, the exception escapes it and the client gets a raw 500. Register it at the very top.
- "Cannot resolve scoped service from root provider": you injected a scoped/DbContext service into a middleware constructor. Take it as an InvokeAsync parameter so a per-request instance is provided.
📋 Quick Reference
Verb / Type
Code
Does
Passthrough step
app.Use(async (c, next) => ...)
Run, then call next()
Terminal step
app.Run(async c => ...)
Ends the pipeline (no next )
Branch by path
app.Map("/health", b => ...)
Sub-pipeline for a path
Branch by predicate
app.MapWhen(c => ..., b => ...)
Branch on any condition
Short-circuit
return; // don't call next
Stop before the handler
Custom middleware
app.UseMiddleware < T > ()
Register a class with InvokeAsync
Action filter
class F : IActionFilter
Run around MVC actions
Custom attribute
[RequireApiKey]
Declarative filter logic
Frequently Asked Questions
Q: What's the difference between app.Use and app.Run ?
Use is a passthrough that gets a next delegate and should call it so later steps run. Run is terminal — it has no next and ends the pipeline, so nothing registered after it ever executes.
The request short-circuits there. Anything registered after that step — including your controllers — never runs. Sometimes that's exactly what you want (a 401 gate); when it isn't, it looks like the request hung or returned nothing.
Q: When should I use a filter instead of middleware?
Use middleware for global concerns that apply to every request (logging, errors, CORS). Use a filter when the logic depends on the chosen MVC action or its model — like validating a specific endpoint's input — because filters run after routing.
Q: Why does the order I register middleware matter so much?
Each step wraps everything registered after it, in and out like nested boxes. So error handling must be first to catch downstream throws, and authentication must come before any step that needs to know who the user is.
Q: Are custom attributes the same as filters?
They're filters in a friendlier wrapper. A class like RequireApiKeyAttribute implements a filter interface (e.g. IAuthorizationFilter ) and inherits Attribute , so you can attach it declaratively with [RequireApiKey] .
Mini-Challenge: a Logging → Auth → Handler chain
No blanks this time — just a brief and an outline. Using the Step helper provided, build the classic middleware chain in plain C#: a Logging step wrapping an Auth step wrapping the handler, and print the in/out order. Run it and check your output against the expected lines in the comments.
🎉 Lesson Complete
- ✅ A request flows in through each middleware, hits the handler, then flows out in reverse
- ✅ Order matters — error handling and logging first, auth before anything that needs the user
- ✅ Use = passthrough, Run = terminal, Map / MapWhen = branch the pipeline
- ✅ Short-circuiting = not calling next() , stopping a request early (e.g. a 401 gate)
- ✅ A middleware class captures next and runs InvokeAsync per request
- ✅ Filters and custom attributes add targeted, declarative cross-cutting logic
- ✅ Next lesson: Entity Framework Core Internals — how EF tracks, translates, and saves your data
Practice quiz
In what order does a request flow through the middleware pipeline relative to the response?
- In and out in the same order
- Randomly
- In through each step, then out in reverse order
- Only inward
Answer: In through each step, then out in reverse order. A request flows down through each middleware to the handler, then back up in reverse — nesting like Russian dolls.
In passthrough middleware, when does code before 'await next()' run?
- On the way in (request)
- On the way out (response)
- Never
- Only on errors
Answer: On the way in (request). Code before await next() runs on the way in; code after it runs on the way out.
What is the difference between app.Use and app.Run?
- Use is terminal; Run is passthrough
- They are identical
- Run runs first always
- Use is a passthrough that gets next(); Run is terminal with no next
Answer: Use is a passthrough that gets next(); Run is terminal with no next. Use is a passthrough that receives and should call next(); Run is terminal and ends the pipeline.
What does 'short-circuiting' the pipeline mean?
- Calling next() twice
- Not calling next(), so the request stops before the handler
- Throwing an exception
- Reordering middleware
Answer: Not calling next(), so the request stops before the handler. By not calling next() (e.g. returning a 403), a step stops the request before it reaches the handler.
Why must exception-handling middleware be registered first?
- So its try/catch wraps every later step and can catch downstream throws
- It runs fastest
- It needs the user identity
- Order doesn't matter
Answer: So its try/catch wraps every later step and can catch downstream throws. A step only protects what comes after it, so error handling must be first to wrap and catch downstream exceptions.
What do app.Map and app.MapWhen do?
- End the pipeline
- Add authentication
- Branch the pipeline based on the path or a predicate
- Log requests
Answer: Branch the pipeline based on the path or a predicate. Map branches by path; MapWhen/UseWhen branch on any predicate, giving requests their own sub-pipeline.
What happens to middleware registered after app.Run?
- It runs first
- It never executes — Run is terminal
- It runs in parallel
- It catches exceptions
Answer: It never executes — Run is terminal. app.Run ends the pipeline, so anything registered after it never runs.
How does a custom middleware class receive the rest of the pipeline?
- As a static field
- It doesn't
- Via a global variable
- Its constructor captures next (a RequestDelegate); InvokeAsync runs per request
Answer: Its constructor captures next (a RequestDelegate); InvokeAsync runs per request. A middleware class takes next (RequestDelegate) in its constructor and implements InvokeAsync(HttpContext) per request.
When should you use a filter instead of middleware?
- For static files
- When the logic depends on the chosen MVC action or its model
- For every request including health checks
- Never
Answer: When the logic depends on the chosen MVC action or its model. Filters run after routing, inside MVC, so use them when the logic needs the action or its bound model; use middleware for global concerns.
Why should you not inject a scoped service into a middleware constructor?
- It's faster as a field
- Scoped services don't exist
- The middleware is a singleton; accept scoped services as InvokeAsync parameters instead
- It causes infinite loops
Answer: The middleware is a singleton; accept scoped services as InvokeAsync parameters instead. Middleware is constructed once (singleton), so take per-request scoped services as InvokeAsync parameters to get a per-request instance.
Continue this course
- Previous: REST APIs
- Next: Entity Framework Core Internals