ASP.NET Core and Data

Lesson 1 of 5

The Middleware Pipeline, in Miniature

"So, naturalists observe, a flea / Hath smaller fleas that on him prey; / And these have smaller still to bite 'em; / And so proceed ad infinitum." — Jonathan Swift, On Poetry: A Rhapsody (1733)

Swift's fleas are a joke about critics and an exact description of a request pipeline. Each piece of middleware sits on top of the next, which sits on top of the next, down to the one that finally does the work — and on the way back out, each of them gets another turn in reverse. Understanding ASP.NET Core begins and very nearly ends with understanding that shape, because almost every behaviour people find surprising about it is a consequence of what wraps what.

This Part assembles the pieces the last one built into something that serves requests. The container, the lifetimes, the configuration and the host are all present in a web application, and the one genuinely new idea is this: the request pipeline. So we build it, in about fifteen lines, and then use it to explain the behaviours that would otherwise be rules to memorise.

The big question

Two applications register identical middleware with an identical endpoint, and differ by the order of two lines. One logs who made each request; the other logs "unknown" every time. One returns a clean 500 page when the endpoint throws; the other returns whatever the server does with an unhandled exception. What is it about the way a pipeline is assembled that makes the order of registration not a style preference but the behaviour itself?

Build the pipeline

The entire mechanism is a list of functions and a fold.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

The output is the shape people draw as an onion: logging → auth → routing → HANDLER → routing → auth → logging. Every middleware sees the request going in and the response coming back out, in opposite orders.

The type is where the design lives. A middleware is (Context, Func<Task>) → Task — it receives the request and a function that represents the entire rest of the application. It may call that function, or not; before doing other work, or after; once, or never. Everything a pipeline can do follows from having the remainder of the work available as a value.

Build is the only clever line, and the cleverness is in the direction. It folds from the end backwards, because each middleware must close over the one that follows it, and that one has to exist first. Note var following = next; — capturing into a fresh local each iteration rather than using the loop variable, which is precisely the closure trap from Part III's LINQ chapter. Without it every middleware would capture the same variable and the pipeline would wire itself into a loop.

The result is a single Func<Context, Task>. By the time a request arrives there is no list, no iteration and no dispatch — just nested closures that already exist.

An analogy

Think of a letter passing through an office. It goes through the post room, then security screening, then a translator, before reaching the person who actually answers it — and the reply comes back out through the same desks in reverse, each adding its stamp or its envelope on the way. Three things follow immediately. Any desk can refuse to pass the letter on, and then nothing downstream ever hears about it. Any desk can see the reply, because the reply travels back through it. And a desk can only deal with problems that happen after its own position in the line, which is why the person who handles complaints sits nearest the door.

Order is the design

A pipeline has no configuration file. The sequence of Use calls is the configuration.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Two identical middleware, one line moved, and one application logs request by ada while the other logs request by (unknown). Nothing is misconfigured in the second; the log line simply executes before the thing that would have told it who the user was.

This is why the conventional ordering is worth understanding rather than copying. Every rule in that table is derivable from the onion:

Exception handling goes first because it is a try/catch around next(), and a try cannot catch what already happened above it. Authentication precedes authorisation because the second asks "may this user do that?" and needs the answer to "which user?". Authorisation precedes the endpoint because rejecting after doing the work has already cost you the work — and, worse, may have written to a database. Static files go early because serving a file needs none of the machinery below it. Compression goes outside most things because it operates on the way back out, and the response must exist before it can be compressed.

None of those is a rule to memorise. They are five consequences of one picture.

A note on history

ASP.NET's original model was event-based: a request raised a fixed sequence of events — BeginRequest, AuthenticateRequest, and some twenty others — and an HttpModule subscribed to the ones it cared about. The pipeline was defined by the framework and could not be reordered; a module that needed to run between two stages that did not exist was out of luck. The function-wrapping model came from elsewhere. Ruby's Rack, in 2007, defined an application as a callable that takes an environment and returns a response, which made middleware simply an application wrapping another application; Node's Connect and then Express brought the same idea to JavaScript with the (req, res, next) signature. .NET's adoption came through OWIN around 2010, an effort to define a server-agnostic interface so that applications were not bound to IIS, and ASP.NET Core in 2016 made that the native model. The gain is the one this chapter is about: a pipeline with no fixed stages, where ordering is expressed by the sequence of calls rather than by a framework's list of events.

Short-circuiting

A middleware that declines to call next() is not broken. It is how a pipeline says no, and how it answers without doing the work.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Three requests, and the expensive handler ran once. The rejected request stopped at authentication; the cached one stopped at the cache. Neither reached the endpoint, and neither paid for it.

The detail worth noticing is in the traces: when a middleware short-circuits, the after halves of everything above it still run. Logging still logged, with the 401 status the auth middleware had just set. That is the onion again — the short circuit cut the inner layers out, and the outer ones completed normally on the way back. It is why a logging middleware at the top sees every response, including the ones no endpoint ever produced.

This is also the mechanism behind a large share of what a real framework ships. Static file serving, response caching, rate limiting, CORS preflight handling, health check endpoints and authorisation failures are all middleware that decide, sometimes, not to call next().

From the notebook

The allocation comparison in the last block of this chapter began as a weaker claim than it deserved. I had measured Build() at 240 bytes and a request at 432, and written that building the pipeline per request "would not be cheap" — but as printed those two numbers say the opposite, since rebuilding costs less than a request does. The comparison was wrong rather than the conclusion: what matters is not Build versus a request but a request versus a request that rebuilds first, which is 432 against 680. Stated that way the cost is real and attributable, and it also makes the scaling argument visible — the rebuild cost grows with the length of the pipeline while the per-request cost does not. Two numbers printed side by side invite a comparison, and it is worth checking that the comparison they invite is the one you meant.

Exception handling, and what it cannot catch

Error-handling middleware is the clearest case of behaviour following directly from position.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Registered first, it catches the endpoint's exception and turns it into a 500 with a body. Registered second, the same exception from a middleware above it escapes the pipeline entirely — the status is still 200 and nothing wrote a response, because nothing caught it.

The implementation explains both outcomes completely. An exception handler is a try around await next(). next() is the rest of the pipeline. Therefore it sees exceptions from everything downstream and is structurally incapable of seeing anything upstream. "Register it first" is not a convention; it is the only position from which it works.

The other detail in that first trace is worth pausing on: routing's after line never ran. An exception thrown downstream unwinds through every middleware between the throw and the catch, so the code after their await next() is skipped. If a middleware needs to do something on the way out regardless — stopping a timer, releasing something, writing a metric — it needs a try/finally of its own. The after half of a middleware is not a guarantee, it is just the next statement.

In the real world

The ordering mistake that turns into a security finding rather than a bug report is a middleware that inspects the request registered above the error handler. Something that parses a header, decodes a token, or reads a correlation id from the query string is added early — reasonably, because everything below it wants the result — and it is written assuming well-formed input. Then a malformed request arrives, it throws, and because it sits above the handler the exception leaves the pipeline untouched. What the client receives depends on the host and the environment: in development that is a full diagnostic page naming your file paths, your framework version and the internals of the call stack; in production it is usually a bare 500, which is better but still an unhandled failure that no middleware logged and no metric counted. Two things make this durable. It only triggers on input nobody sends by accident, so it survives every test and every normal day until somebody probes the service. And the fix looks like a reordering rather than a bug fix, so it is easy to lose in review. The defence is the position: the error handler is the first Use call in the program, with nothing above it, and anything that must run earlier is written on the assumption that it cannot throw.

Composed once

The pipeline is assembled at startup, and that has a consequence for state that catches people.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

The counter climbs 1, 2, 3, 4, 5 across five requests, because the middleware object was constructed once and captured into the composed delegate. Every request runs through the same instance.

That makes a middleware field singleton state, and the whole of Part V's lifetimes chapter applies to it without modification. It must be thread-safe, because requests arrive concurrently and that field is shared. It must not hold anything scoped, or it is a captive dependency — which is why a real middleware that needs a scoped service takes it as a parameter on Invoke rather than in its constructor, so the framework can resolve it per request. And anything that genuinely belongs to one request goes on the context, which is what the items dictionary is for.

The allocation figures say the composition is worth caching: a request through the built pipeline costs 432 bytes, and a request that rebuilds the pipeline first costs 680. The difference is the Build call, and it scales with the number of middleware while the per-request cost does not. Three middleware is a small penalty; a realistic pipeline of twenty would not be.

Common Mistakes

  1. Registering exception handling after something that can throw. It is a try around next(), so it cannot see anything above it. Anything that throws upstream escapes the pipeline entirely.

  2. Putting authorisation after the endpoint. The work happens and is then rejected, which costs the work and may have already written to a database.

  3. Expecting the code after await next() to always run. A downstream exception unwinds straight past it. Use try/finally if it must happen.

  4. Mutable fields on a middleware class. The instance is a singleton shared by every concurrent request, so an unsynchronised field is the data race from Part IV.

  5. Injecting a scoped service into a middleware constructor. That is a captive dependency. Take it as a parameter on Invoke so it is resolved per request.

  6. Writing to the response after calling next(). By then the response may have started being sent, and headers in particular can no longer be changed.

  7. Thinking a short circuit skips everything. It skips what is below it; the after halves of everything above still run and still see the response.

  8. Building the pipeline inside the request path. The composition is startup work, and paying it per request adds a cost that scales with pipeline length.

  9. Capturing the loop variable when composing by hand. Each iteration must capture a fresh local, or every middleware closes over the same variable.

  10. Treating middleware order as style. It is the behaviour. Two identical registrations in different orders are two different applications.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
What is middleware, and why does the order matter?
Model answer — A piece of middleware is a function that takes the request context and a delegate representing the rest of the pipeline. It can do work before calling that delegate, after it, both, or it can decline to call it at all. Because each one wraps the next, the request passes through them in registration order and the response comes back out in reverse — the shape people draw as an onion. The order matters because it is the behaviour: a logging middleware registered before authentication will log that the user is unknown, because authentication has not run yet. Moving one line changes what the application does, and there is no separate configuration that overrides it.
Intermediate
Why must exception-handling middleware be registered first?
Model answer — Because it is literally a try/catch around its call to next(), and next() is the rest of the pipeline. So it can only catch exceptions thrown by things that run after it. If you register something above it and that thing throws, the exception propagates straight out of the pipeline and whatever the server does with an unhandled exception is what the client gets — in my own test the status was still 200 with no body written, because nothing caught it. The related detail is that an exception unwinds past the code after await next() in every middleware between the throw and the catch, so the "after" half of a middleware is not a guarantee. If something must happen on the way out regardless, it needs a try/finally.
Advanced
A middleware needs a scoped service. How do you give it one, and why not the constructor?
Model answer — By taking it as a parameter on the Invoke method, so the framework resolves it from the request's scope on each call. Not the constructor, because middleware is constructed once when the pipeline is composed at startup and that instance is reused for every request — so a constructor-injected scoped service would be resolved once, from whatever scope existed then, and held for the life of the application. That is the captive dependency problem: every request would share one instance of something meant to last a single request, which for a database context means shared tracked entities and no thread safety. The same reasoning applies to any state the middleware holds. A field on a middleware class is singleton state shared by concurrent requests, so it needs the same treatment as any other singleton — thread-safe, or not mutable at all — and anything genuinely per-request belongs on the context rather than on the middleware.
Company lens — how the same topic gets asked
micro1Direct: what middleware is, and the order of the common components.
GoogleThe model — a pipeline as nested closures, and what short-circuiting makes possible.
MetaCode review: a scoped service in a middleware constructor, or mutable middleware state.
AmazonOperational: an unhandled exception reaching the client because the handler was registered too late.

Hands-on

Hands-on

Write the three rules this chapter established: the order a request passes through the pipeline, whether an exception handler can catch a given failure, and where a piece of state belongs. The harness scores ten checks, each pairing a case that must be true with one that must be false. A correct solution prints score 10/10.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Run it unchanged first: ten failures and score 0/10. A stub that scores above zero means a check is not testing what it claims.

Summary

  • A pipeline is a list of functions folded into nested closures. Each middleware receives the request and a delegate representing the entire rest of the application.
  • Build folds from the end backwards, because each middleware must close over the one after it — capturing a fresh local each iteration, or they all close over the same variable.
  • The onion is the shape: every before in registration order, the handler, then every after in reverse. A middleware sees both the request and the response.
  • Order is the configuration. Identical middleware with two lines swapped produced request by ada and request by (unknown) from the same application.
  • The conventional ordering is derivable, not arbitrary — errors first because a try cannot catch upstream, authentication before authorisation, authorisation before the work it would reject.
  • Declining to call next() short-circuits the rest, which is how rejection, caching, static files and rate limiting avoid paying for the endpoint.
  • A short circuit skips only what is below it. The after halves above still run and still see the response.
  • Exception middleware is a try around next(), so it catches everything downstream and nothing upstream — and a thrown exception unwinds past the after code of everything in between.
  • A middleware instance is a singleton. Its fields are shared by concurrent requests, so a scoped dependency belongs on Invoke and per-request state belongs on the context.
  • Composition is startup work: 432 bytes for a request on a built pipeline against 680 for one that rebuilds first, and the gap grows with pipeline length.

In the next chapter, Minimal APIs, Binding and Validation, we reach the end of the pipeline: how a route pattern becomes a method call, where the arguments to that method come from, what happens when the incoming data does not fit the parameters it is supposed to fill, and why returning the right status code is harder than it looks.

Editorial reflection

It is striking how much of a large framework reduces to one type signature. (Context, Func<Task>) → Task is the whole of the pipeline: short-circuiting, error handling, response post-processing, the ordering rules, and the reason middleware is effectively singleton all fall out of it without further machinery. That is what a good abstraction looks like from the inside — not something that does a lot, but something shaped so that a lot follows. The practical habit it suggests is to go looking for that signature whenever a framework feels like a collection of rules. Usually there is one underneath, the rules are its consequences, and the time spent finding it is repaid the first time you hit a case the documentation did not anticipate.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.