The RequestDelegate
Here's the idea that the previous phase was dancing around, said plainly. There is exactly
one type at the bottom of the whole pipeline, and once you see it, every app.Use(...),
every middleware class, every MapGet stops being a separate concept and becomes the same
shape wearing different clothes.
The shape is this:
- A
RequestDelegateis a function fromHttpContextto aTask— "give me a request, I'll handle it and hand you back aTaskfor when I'm done." - A middleware is a function that takes the next
RequestDelegateand returns a newRequestDelegate— it wraps the rest of the pipeline so it can do work before and after.
And the punchline: your entire app is ultimately one RequestDelegate. All your middleware,
composed together, collapse into a single function that Kestrel hands each HttpContext to.
Hold those two sentences and the rest of this phase is detail.
📝 This is the deepest phase in the guide. It assumes you're comfortable with C# delegates,
async/await, and the DI container (C# From Zero and the previous two phases of this guide cover the ground you need). IfUse/Run/Maparen't familiar yet, read Phase 3 first.
The atom: RequestDelegate
The type itself is almost anticlimactic:
public delegate Task RequestDelegate(HttpContext context);
What just happened: We named a delegate type. A RequestDelegate is any method (or lambda)
that takes one HttpContext and returns a Task. That's the entire contract — no return value
beyond the Task, because the "response" isn't returned, it's written into context.Response.
Your endpoint handler is a RequestDelegate. The thing Kestrel invokes per request is a
RequestDelegate. It is the atom the whole pipeline is built from.
app.Use is sugar for Func<RequestDelegate, RequestDelegate>
Now the second half. When you write inline middleware with app.Use, what you're really
describing is a function that wraps the next delegate — conceptually a
Func<RequestDelegate, RequestDelegate>. It receives next (everything registered after it,
already composed into one delegate) and returns a brand-new RequestDelegate that does some
work, calls next, and does more work on the way back.
Written out without the app.Use sugar, a logging middleware looks like this:
// app.Use(...) is sugar for composing RequestDelegates. Conceptually:
RequestDelegate Logging(RequestDelegate next) => async context =>
{
var start = DateTime.UtcNow;
await next(context); // call the rest of the pipeline
var ms = (DateTime.UtcNow - start).TotalMilliseconds;
context.RequestServices.GetRequiredService<ILogger<Program>>()
.LogInformation("{Path} {Status} {Ms}ms", context.Request.Path, context.Response.StatusCode, ms);
};
What just happened: Logging is a function that takes next (a RequestDelegate) and
returns a new RequestDelegate — the async context => { ... } lambda. Inside that lambda we
do work before (start), then await next(context) to run the rest of the pipeline, then do
work after (compute ms, log). The "before" and "after" sit on either side of the one
await next call. That symmetry — code before, call next, code after — is the whole story of
middleware, and it's exactly what app.Use(async (context, next) => { ... await next(context); ... })
compiles down to. The framework just spares you from naming the wrapping function.
💡 Notice where
nextcomes from: it's already the rest of the pipeline, pre-composed into a singleRequestDelegate. Each middleware only ever sees "me and everything after me as one function." It never needs to know how many middlewares follow or what they are.
Convention-based middleware classes
Inline lambdas are fine for two-line concerns, but real middleware usually wants a class — its own file, constructor, testability. ASP.NET Core supports a convention-based class: no interface to implement, no base class to inherit. You just follow a shape:
public class LoggingMiddleware
{
private readonly RequestDelegate _next;
public LoggingMiddleware(RequestDelegate next) // the "next" delegate, captured once
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, ILogger<LoggingMiddleware> logger)
{
var start = DateTime.UtcNow;
await _next(context);
var ms = (DateTime.UtcNow - start).TotalMilliseconds;
logger.LogInformation("{Path} {Status} {Ms}ms",
context.Request.Path, context.Response.StatusCode, ms);
}
}
// register it:
app.UseMiddleware<LoggingMiddleware>();
What just happened: This is the same logging middleware as before, restructured. The
constructor takes RequestDelegate next and stashes it — that's the "wrap the next delegate"
part. The InvokeAsync method is the new RequestDelegate body: it gets the HttpContext,
does before/after work around await _next(context). app.UseMiddleware<LoggingMiddleware>()
recognizes the convention (constructor-takes-next, has InvokeAsync(HttpContext, ...)) and
slots it into the pipeline. Note ILogger arrives as a parameter of InvokeAsync, not the
constructor — and that detail is not cosmetic.
⚠️ The captive-dependency trap. A convention-based middleware instance is constructed once, for the lifetime of the app — effectively a singleton. So anything you inject into the constructor is also captured once and reused for every request forever. If you inject a Scoped service (a
DbContext, a per-request unit-of-work) into the constructor, you've captured one request's instance and frozen it across all future requests — a "captive dependency," and a genuinely nasty bug (stale data, cross-request leakage, disposed-object exceptions). The fix is the rule above: inject per-request (Scoped) services as parameters ofInvokeAsync, which the framework resolves fresh from the request's scope on every call. Constructor injection is only safe for Singleton services. (More on service lifetimes in Phase 5.)
Composition order: last registered is innermost
So how do the pieces become one function? When the app builds, the framework composes the
middlewares from the last registered to the first. Each one's Func<RequestDelegate, RequestDelegate>
is handed the already-composed delegate of everything after it. The result: the first Use
you wrote becomes the outermost wrapper, and the last becomes the innermost (sitting right
next to your endpoint).
app.Use(/* A */ ...); // outermost: runs first on the way in, last on the way out
app.Use(/* B */ ...);
app.Use(/* C */ ...); // innermost: closest to the endpoint
app.Run(/* endpoint */);
What just happened: Reading top-to-bottom is the order requests enter: A, then B, then C,
then the endpoint. Because each middleware does work after await next, the way out is the
mirror image: endpoint, then C, then B, then A. Composition built this nesting by wrapping
inside-out — A(B(C(endpoint))) — which is why registration order is the single most important
thing about a pipeline (exactly the lesson from Phase 3, now
explained from the inside).
💡 If this "a function that takes
nextand returns a new function" shape feels familiar, it's because it's the universal pattern for middleware, not a .NET invention. Go's idiom is literallyfunc(next http.Handler) http.Handler— same signature, same wrapping. And Rust's tower expresses it as aLayerthat wraps aServiceto produce a newService— the parallel is exact, down to "compose inside-out so the first layer is outermost." If you've internalized one, you've internalized all three. See hyper & tower for the Rust telling of the very same idea.
Recap
- A
RequestDelegateisTask RequestDelegate(HttpContext context)— a function from a request to aTask. Your endpoint is one; the whole pipeline collapses into one. - A middleware is a function that takes the next
RequestDelegateand returns a new one — work before,await next(context), work after.app.Useis sugar for exactly this (Func<RequestDelegate, RequestDelegate>). - Convention-based middleware classes take
RequestDelegate nextin the constructor and exposepublic async Task InvokeAsync(HttpContext context, ...), registered withapp.UseMiddleware<T>(). - ⚠️ The instance is created once. Inject Scoped services as
InvokeAsyncparameters, never the constructor — constructor injection of Scoped services is the captive-dependency bug. - The pipeline composes last-registered-innermost, so the first
Useis the outermost call — which is why ordering decides everything. - It's the same middleware shape as Go's
func(next) handlerand Rust tower'sLayer/Service.
Quick check
[
{
"q": "What is a RequestDelegate?",
"choices": ["A class you inherit from to make middleware", "A function from HttpContext to a Task — the atom the pipeline is built from", "A DI lifetime like Scoped or Singleton", "The Kestrel socket listener"],
"answer": 1,
"explain": "RequestDelegate is `Task RequestDelegate(HttpContext context)` — a function that handles a request and returns a Task. Middleware and endpoints are all this shape, and the whole app composes into one."
},
{
"q": "Conceptually, what is a middleware?",
"choices": ["A function that takes the next RequestDelegate and returns a new RequestDelegate", "A subclass of HttpContext", "A method that returns the response object directly", "A configuration source"],
"answer": 0,
"explain": "Middleware is a Func<RequestDelegate, RequestDelegate>: it receives `next` (the rest of the pipeline) and returns a new delegate that does work, awaits next, and does more work on the way out. app.Use is sugar for this."
},
{
"q": "Why inject a Scoped service into InvokeAsync rather than the middleware constructor?",
"choices": ["Constructor injection is slower", "The middleware instance is created once, so a constructor-injected Scoped service becomes a captive dependency reused across all requests", "InvokeAsync can't access the constructor", "Scoped services aren't registered until InvokeAsync runs"],
"answer": 1,
"explain": "A convention-based middleware is instantiated once (singleton-like). A Scoped service captured in its constructor is frozen for the app's lifetime — the captive-dependency bug. InvokeAsync parameters are resolved fresh from each request's scope."
}
]
← Phase 3: The Middleware Pipeline · Guide overview · Phase 5: The Host, DI & Configuration →
Check your understanding
1. What is a RequestDelegate?
2. Conceptually, what is a middleware?
3. Why inject a Scoped service into InvokeAsync rather than the middleware constructor?