ASP.NET Core Fundamentals: Middleware, DI, and the Request Pipeline
Understand ASP.NET Core middleware: pipeline order, Use, Run, and Map, writing custom middleware classes, endpoint routing, and how Kestrel fits in.
The bug that teaches middleware order usually looks like this: a frontend calls your new endpoint. The browser console reports Access to fetch at 'http://localhost:5217/tasks' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present, even though CORS is configured. It is configured, and it is registered one line too late, after the endpoint already answered. The same class of bug breaks authorization (a 401 where a 404 was correct, or access where a 401 belonged), because both live in the pipeline, not in the controllers.
ASP.NET Core middleware is the request pipeline mechanism: components registered in Program.cs, each receiving the request, doing its piece of work, and handing the request to the next component; code after that handoff runs on the way back out. The official middleware overview is the reference for everything on this page.
The examples assume .NET SDK 10.0 with ASP.NET Core 10 and use the recurring TaskApi: a small API for tracking tasks, projects, and tags. If you have not created a web project yet, dotnet new webapi -o TaskApi produces one, and the Web API guide walks through the whole controller side.
In my experience, the pipeline is the most under-reviewed part of most codebases. Teams argue about class design and then let Use calls drift between pull requests, even though a line in the wrong position changes security and behavior for every endpoint in the application.
The request pipeline: what middleware is
Picture every HTTP request entering one end of a chain and the response leaving the other. Each middleware component runs twice: once on the way in, and once on the way out, after it calls the next component.
REQUEST RESPONSE
| ^
v |
+-------------------------------------------------+
| UseHttpsRedirection: [in] ------------------> [out]
+-------------------------------------------------+
| Routing: [in] -- pick endpoint --> [out]
+-------------------------------------------------+
| Authentication: [in] -- who is this? ---> [out]
+-------------------------------------------------+
| Authorization: [in] -- may they in? ---> [out]
+-------------------------------------------------+
| Endpoint: execute controller or minimal API
+-------------------------------------------------+
A component can also stop the chain: UseHttpsRedirection answers with a redirect and never calls the rest. Authentication middleware returns 401 and stops. That is the whole model. Everything else, CORS, compression, rate limiting, is more middleware in the same line.
Reading a Program.cs pipeline
A controller-based web API opens with roughly this file, and each line earns its place:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
builder.Services is the registration half: ASP.NET Core ships with a built-in dependency injection container that constructs your services and their dependencies. The dependency injection guide covers lifetimes and registration patterns in depth. The app lines are the pipeline half, in execution order. Two halves, one file: services configure what exists, middleware configures what runs per request.
Use, Run, and Map: the three verbs
Three verbs describe every pipeline shape. Use registers a component that runs and passes control on. Run registers a terminal component that never passes control. Map branches the pipeline on a path:
app.Use(async (context, next) =>
{
var watch = Stopwatch.StartNew();
await next(context); // run the rest of the pipeline
watch.Stop();
Console.WriteLine($"{context.Request.Path} took {watch.ElapsedMilliseconds} ms");
});
app.Map("/health", () => "healthy"); // branch: /health never reaches later middleware
app.Run(async context =>
{
await context.Response.WriteAsync("End of the line");
});
The timing example above is the right shape of most custom middleware: do something before await next(context), do something after, and stay small. Middleware registered with Map gets its own branch, which is how health endpoints and sub-applications live outside the main pipeline.
Order decides everything
Middleware is a queue, and the queue order is behavior. The canonical order for an API, with the reason each position is fixed:
| Position | Middleware | Why it sits here |
|---|---|---|
| 1 | Exception handler / Problem Details | Must wrap everything downstream to convert exceptions into responses |
| 2 | HTTPS redirection, HSTS | Answers or upgrades transport before anything else runs |
| 3 | Static files | Short-circuits known paths without touching your code |
| 4 | Routing | Selects the endpoint so later middleware knows what is being requested |
| 5 | CORS | Applies the policy the endpoint needs, including preflight responses |
| 6 | Authentication | Establishes who the caller is; must precede authorization |
| 7 | Authorization | Decides access; needs both the endpoint and the identity |
| 8 | Your custom middleware | Runs with identity and endpoint both known |
| 9 | Endpoint mapping (MapControllers) |
The terminal stage: your actual code |
Two mistakes dominate production incidents. CORS after endpoint mapping means the endpoint answers before the CORS middleware adds its headers, which is the browser error from the introduction. Authentication after authorization reverses “who” and “may”, and authorization with no identity fails closed in confusing ways. The authentication and authorization guide covers those two components as a pair.
Writing custom middleware
Inline lambdas grow badly, so the convention is a class with an InvokeAsync method plus a small extension method to register it:
public sealed class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
public RequestTimingMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var watch = Stopwatch.StartNew();
await _next(context);
watch.Stop();
context.Response.Headers["X-Elapsed-Ms"] =
watch.ElapsedMilliseconds.ToString(CultureInfo.InvariantCulture);
}
}
public static class RequestTimingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app) =>
app.UseMiddleware<RequestTimingMiddleware>();
}
Registration is then one readable line in the pipeline: app.UseRequestTiming();. Constructor injection builds the class once per application. InvokeAsync may also take scoped services as parameters, which is the recommended way to touch per-request state without the service locator pattern. For middleware that wants constructor injection of scoped services or is used in different pipelines, the IMiddleware factory-based variant exists. Start with the convention-based class and switch only if the constraints bite.
Endpoint routing: where middleware hands off to your code
Somewhere in the chain, routing middleware matches the URL to an endpoint, a controller action, a minimal API handler, or a health check, and stores it on the HttpContext. Endpoint mapping calls like MapControllers() or MapGet("/tasks", ...) register those endpoints at startup. The routing fundamentals document covers the matching rules. What matters here is the pipeline view: an endpoint is the terminal middleware, and everything before it is deciding how, whether, and for whom it runs.
In minimal-hosting templates, routing is implicit: WebApplication adds UseRouting and UseEndpoints around your registration order. That is why a template with no explicit routing line still works. When you add middleware that must run between them, write the pipeline explicitly with app.UseRouting() and app.UseEndpoints(...) so the order is visible rather than implied.
Kestrel: the server underneath it all
Everything above runs inside Kestrel, the web server ASP.NET Core ships with. Kestrel accepts connections, parses HTTP, and hands each request to the pipeline as an HttpContext. You can deploy Kestrel directly to the internet. The common production shape is Kestrel behind a reverse proxy (nginx, IIS, Azure App Service front end) that terminates TLS and forwards requests. The Kestrel reference covers configuration, and configuration is all you usually touch: ports, headers, and limits belong in appsettings.json, not custom middleware.
How real systems do this
- Cross-cutting concerns are middleware, and they are small. Request timing, correlation ids, tenant resolution, feature flags: one class each, one registration line each, testable with the pipeline in an integration test. The Serilog guide shows request logging middleware that enriches every log line with the correlation id.
- Exception handling is middleware first. A single component near the top converts unhandled exceptions into consistent responses and logs with the trace id. The Problem Details guide covers the whole strategy.
- Pipeline changes get code review. Teams treat
Program.csdiffs like security-sensitive changes. Any move of aUseline is called out in the description, because order is behavior.
A bug I have seen in production is exactly the CORS one from the introduction. A new endpoint worked locally, the staging frontend got the preflight rejection. Two people spent an afternoon on the CORS configuration before anyone looked at registration order. Moving UseCors above MapControllers fixed it. The lesson that stuck: when a header or status seems to ignore its configuration, read the pipeline before reading the configuration.
Decision framework: middleware, filter, or endpoint
- Does it concern every request? Middleware: timing, ids, security headers, rate limits run for endpoints and static files alike.
- Does it concern only controller actions? Action filters, model binding, and endpoint-specific policies belong in the MVC layer, not the pipeline. See the Web API guide for where each hook lives.
- Should some paths bypass it?
Mapa branch: health checks and metrics usually deserve their own branch with no authentication. - Can a component already in the box do it? CORS, rate limiting, output caching, and response compression are built-in. Using them is one registration line and a decade of fixes for free.
- Does it need to change response headers after the endpoint runs? Register before the endpoint and write headers after
await next, but never after the response body has started, or the runtime throws.
When NOT to write custom middleware
- When it is business logic. Middleware runs for every request and sees no endpoint-specific input. An endpoint, or at most an action filter, is where request-specific rules live.
- When a built-in exists. Hand-rolled CORS, caching, or rate limiting re-implements security-sensitive code with one tester. The framework components cover edge cases most custom versions miss, such as preflight requests and vary headers.
- When it must be conditional and the conditions keep growing. A middleware with five
ifstatements about paths and methods is a routing problem wearing middleware clothes; branch withMapor use endpoint metadata instead.
Common mistakes
- Registering middleware in the wrong order. CORS after mapping, or authorization before authentication, changes behavior for every endpoint. The canonical order table above is the review checklist.
- Not calling next, or calling it twice. Forgetting
await next(context)silently short-circuits the rest of the pipeline; calling it twice double-executes everything downstream. - Writing to the response after it has started. Headers must be set before the first body write; late writes throw
InvalidOperationException(“Headers are read-only, response has already started”). - Swallowing exceptions with a bare catch. A try/catch that logs and returns 200 hides failures from health checks and error tracking; delegate to the exception handler and Problem Details instead.
- Business logic in middleware. The pipeline sees raw requests, not your models. Rules that need endpoints, models, or validation belong in the endpoint or a filter.
- Growing inline lambda middleware. Anything past a few lines becomes a class with a name and tests. Unnamed closures are where pipeline behavior goes to be forgotten.
Key takeaways
- Every request runs through one ordered chain of middleware; order is behavior, and
Program.csdeserves review attention. Usepasses control on,Runterminates,Mapbranches; most custom middleware is work before and afterawait next(context).- Exception handling first, routing before CORS and auth, authentication before authorization, endpoints last.
- Custom middleware is convention-based: a class with
InvokeAsync, registered through aUseextension method, with scoped services as method parameters. - Endpoints are the terminal stage; prefer built-in middleware (CORS, rate limiting, caching) over hand-rolled versions.
- Kestrel is the server: configuration usually stays in appsettings, and the reverse proxy pattern is the common production shape.
FAQ
What is middleware in ASP.NET Core?
A component in the request pipeline that handles part of every HTTP request and passes it to the next component. Middleware registered with Use can act before and after the rest of the chain, Run terminates the chain, and Map branches it by path. Examples include HTTPS redirection, routing, CORS, authentication, and your own timing or logging classes.
Why does middleware order matter in ASP.NET Core?
Because each component depends on what earlier components did. Routing must select an endpoint before authorization can evaluate it, authentication must establish identity before authorization can check it. The exception handler must sit first to catch downstream failures. A line registered one position late is a bug affecting every endpoint, which is why pipeline diffs deserve careful review.
What is the difference between Use, Run, and Map?
Use registers a component that does its work and calls the rest of the pipeline. As a result, it can act on the way in and the way out. Run registers a terminal component that ends the pipeline. Map branches the pipeline by path, so a segment like /health can bypass authentication and everything after the branch point.
How do I write custom middleware in ASP.NET Core?
Create a class with a constructor taking RequestDelegate and a public InvokeAsync(HttpContext) method, then register it with an extension method that calls app.UseMiddleware. Scoped services are injected as InvokeAsync parameters rather than the constructor. Keep the class small and cross-cutting; request-specific logic belongs in endpoints or filters.
What is Kestrel and do I need it?
Kestrel is the web server built into ASP.NET Core; it accepts connections, parses HTTP, and feeds the middleware pipeline. Every ASP.NET Core app runs on it, whether exposed directly or behind a reverse proxy like nginx, IIS, or Azure App Service. You rarely configure it beyond ports, TLS, and request limits, which live in appsettings.json.
Read the pipeline like a function
The pipeline is the one function every request executes, and its order is your application’s real architecture. Keep the canonical order, register built-in components instead of writing them, wrap cross-cutting concerns in small testable classes. Treat any pipeline diff as a behavior change that needs a reviewer’s eyes. Next in the series, building a Web API with ASP.NET Core picks up at the terminal stage of the pipeline. All the guides live on the .NET series index. The rule of thumb: when behavior confuses you, read the pipeline top to bottom before reading anything else.
Last updated on 24 October 2026