Hosting and the request pipeline
What WebApplication builds at startup, and why middleware nests like an onion instead of running as a queue.
After this lesson you can
- Say what CreateBuilder, Build and Run each do
- Explain how app.Use composes middleware into one RequestDelegate
- Predict the order in which code before and after next() runs
- Explain why setting a header after next() can throw
Every ASP.NET Core app starts the same way:
var builder = WebApplication.CreateBuilder(args); // configuration, logging, DI
builder.Services.AddSingleton<IClock, SystemClock>();
var app = builder.Build(); // the container is now fixed
app.Use(async (context, next) =>
{
// runs on the way in
await next(context);
// runs on the way out
});
app.MapGet("/orders/{id:int}", (int id) => Results.Ok(id));
app.Run(); // start Kestrel and block
CreateBuilder loads configuration, sets up logging and gives you an
empty IServiceCollection. Build() turns that collection into the root
service provider; after it, builder.Services is read-only and adding a
registration throws. Everything after Build() describes the request
pipeline, and Run() starts the server.
A pipeline is one delegate
A RequestDelegate is a function Task (HttpContext). A middleware is a
function that takes the next delegate and returns a new one that wraps
it: Func<RequestDelegate, RequestDelegate>. app.Use only appends one of
those to a list. When the pipeline is built, the list is folded from the
last component to the first, each one wrapping everything after it.
The innermost next is a terminal delegate that answers 404 when no
endpoint handled the request. The result is a single delegate, and the
server calls it once per request.
The example models this with plain delegates: two components and the 404 terminal.
public delegate void RequestDelegate(Context ctx); public class Context{ public List<string> Log { get; } = new(); public int Status { get; set; } = 200;} public static class Solution{ public static List<string> Run() { var components = new List<Func<RequestDelegate, RequestDelegate>> { next => ctx => { ctx.Log.Add("A:in"); next(ctx); ctx.Log.Add("A:out " + ctx.Status); }, next => ctx => { ctx.Log.Add("B:in"); next(ctx); ctx.Log.Add("B:out " + ctx.Status); }, }; // Nobody handled the request: the terminal answers 404. RequestDelegate app = ctx => { ctx.Status = 404; ctx.Log.Add("terminal"); }; // Fold from the last component to the first. for (int i = components.Count - 1; i >= 0; i--) app = components[i](app); var context = new Context(); app(context); return context.Log; }}Code before next runs on the way in, in registration order. Code after
next runs on the way out, in reverse. The first component registered is
the outermost layer: a stopwatch there times the whole request, and an
exception handler there sees every exception thrown below it.
Built once, run many times
The second model separates the two phases: three component factories, one build, two requests.
public static class Solution{ public static List<string> Run() { var log = new List<string>(); var components = new List<Func<Action, Action>>(); foreach (var name in new[] { "A", "B", "C" }) { components.Add(next => { log.Add("build " + name); return () => { log.Add("run " + name); next(); }; }); } Action app = () => log.Add("terminal"); for (int i = components.Count - 1; i >= 0; i--) app = components[i](app); log.Add("request 1"); app(); log.Add("request 2"); app(); return log; }}The factories run once, last first, when the pipeline is built; the
delegates they return run on every request, first first. This is why a
convention-based middleware class (app.UseMiddleware<T>()) is
constructed only once for the life of the app. A scoped service such as a
DbContext must therefore be a parameter of its InvokeAsync method, not
of its constructor.
The response can start before you get control back
As soon as an endpoint writes to the response body, the status line and
headers are sent to the client and context.Response.HasStarted becomes
true. Code that runs after await next(context) may be too late: setting
a header at that point throws an InvalidOperationException. Set headers
before calling next, or register a callback with
context.Response.OnStarting(...), which runs just before the headers go
out.
Common mistakes
- Calling
next(context)withoutawait. The code after it runs while the inner components are still working, and an exception they throw never reaches this middleware'stry/catch. - Setting a response header after
await next(context). It works in a test where the endpoint writes nothing and throws in production where it writes a body. - Injecting a scoped service into a middleware class's constructor. The class is built once, so the first request's instance is reused forever (the factory in the second model ran once for two requests).
Try it yourself
2 visible tests · 3 hidden testsModel a pipeline whose components each set one response header.
middleware lists components in registration order; each entry is
"Name:before" (sets its header before calling next) or "Name:after"
(sets its header after next returns). The endpoint at the end either
writes a body, which starts the response, or writes nothing.
Implement HeaderTrace(List<string> middleware, bool endpointWritesBody)
returning the events in the order they happen:
"set Name"when a component sets its header successfully;"endpoint:body"or"endpoint:empty"when the endpoint runs;"reject Name"when anaftercomponent tries to set its header but the response has already started.
A before component always succeeds, because nothing has started yet.
HeaderTrace(["Cors:before","Timing:after"], true)HeaderTrace(["Cors:before","Timing:after"], false)
Sign up to run the tests, check the hidden ones and save your progress. Sign up