Middleware and Pipeline in ASP.NET Core
Every HTTP request passes through a line of Middleware. Each Middleware can do work before and after the others, or end the request right there. The order of this line decides how the application behaves.
Author: bezzad
The problem: repeated work in every endpoint
Our shop has an API. It has several endpoints: the product list, the shopping cart, placing an order. Apart from its main job, every request needs some shared work:
- Logging. Who, which address, how long did it take?
- Error handling. If an error happens, a clean 500 answer must come back, not the internal error text.
- Knowing the user. Read the token and find out who the user is.
- Checking permission. Is this user allowed to place an order?
If we repeat this code in every endpoint, sooner or later we forget one. So we write this work once and outside the endpoint. We call each of these pieces a Middleware. We call their line the Pipeline.
The idea: a line of layers
Each Middleware is a function that takes two things:
- The current request. This is the HttpContext object. Everything about the request and the response is inside it.
- The next step. This is a function named next that calls the next Middleware.
So each Middleware has three choices:
- Do some work before next. For example, take the start time.
- Call the next method. The request goes to the next layer. When it comes back, do some work after it. For example, calculate the time.
- Do not call the next method. The request ends right here and does not reach the later layers. We call this a Short-circuit.
Live example
Pick a request and see which layers it passes through and where it ends:
Order matters
The order of the Middleware is the same order in which you add them in the Program.cs file. The framework does not change this order. This is a suggested order for the shop API:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseExceptionHandler(); // 1. outermost: catches errors from all inner layers
app.UseHttpsRedirection(); // 2. send http to https
app.UseStaticFiles(); // 3. images and css end here (short-circuit)
app.UseRouting(); // 4. find which endpoint matches the URL
app.UseAuthentication(); // 5. who is the user?
app.UseAuthorization(); // 6. is the user allowed to call this endpoint?
app.MapGet("/products", (ShopDb db, CancellationToken ct) =>
db.Products.ToListAsync(ct));
app.MapPost("/orders", (PlaceOrder cmd, OrderService orders, CancellationToken ct) =>
orders.PlaceAsync(cmd, ct))
.RequireAuthorization();
app.Run();
Why this order? Step by step:
- Error handling is first. It only catches errors from layers that are inside it.
- Static files come before authentication. A product image does not need a token. So we answer sooner and do no extra work.
- Routing comes before authorization. Authorization must know which endpoint was chosen, so it can read the rule of that endpoint.
- Authentication comes before authorization. First we must know who the user is, then we ask if they are allowed.
Writing a Middleware
The short way: a function
To time each request, a short function is enough:
app.Use(async (context, next) =>
{
var start = Stopwatch.GetTimestamp();
await next(context); // run the rest of the pipeline
var elapsed = Stopwatch.GetElapsedTime(start);
app.Logger.LogInformation("{Path} took {Ms} ms",
context.Request.Path, elapsed.TotalMilliseconds);
});
The code before next runs on the way in. The code after next runs on the way back.
The full way: a class
When a Middleware is bigger or has dependencies, we make it a class. This Middleware gives each request a tracking ID (Correlation Id), so we can easily find the logs of one order:
public sealed class CorrelationIdMiddleware(RequestDelegate next)
{
private const string Header = "X-Correlation-Id";
// Scoped services come in as parameters of InvokeAsync, not the constructor.
public async Task InvokeAsync(HttpContext context, ILogger<CorrelationIdMiddleware> logger)
{
var id = context.Request.Headers[Header].FirstOrDefault()
?? Guid.NewGuid().ToString();
// Set headers before calling next. After the body starts, headers are locked.
context.Response.Headers[Header] = id;
using (logger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = id }))
{
await next(context);
}
}
}
// Program.cs
app.UseMiddleware<CorrelationIdMiddleware>();
Middleware lifetime: one instance for everyone
This point is asked a lot in interviews. A Middleware class is created only once. That single instance answers all requests. So it behaves like a Singleton.
Why is taking a DbContext in the constructor dangerous?
- The constructor runs once. So only one DbContext is created.
- All requests use that same one. But DbContext is not built for work from several Threads at the same time.
- Result: concurrency errors, old data, and memory that keeps growing.
Request cancellation: when the user has left
A customer opens the sales report and closes the page before it finishes. The framework notices this and cancels the RequestAborted token on HttpContext. But this is only a signal:
- If you do not pass the token, the database query runs to the end and its result is thrown away.
- If you take a parameter of type CancellationToken, the framework gives you this same token. Pass it to EF Core and HttpClient so the work really stops.
- An OperationCanceledException error is normal in this case. Do not log it as a serious error.
In the code above, the products endpoint does exactly this and gives the token to the ToListAsync method.
Middleware or Filter?
Both run shared code, but in two different places:
Middleware
- Runs for all requests, even static files.
- Sees only the HttpContext.
- Does not know the endpoint’s input model.
- Good for logging, errors, HTTPS, compression, authentication.
Filter
- Runs only for endpoints.
- Sees the endpoint’s arguments (for example, the order model).
- Can be on one endpoint or on a group.
- Good for model validation or a rule that depends on the endpoint’s input.
Important rules
- Set the order on purpose. Error handling first, then routing, then authentication, then authorization, then the endpoint.
- Set the headers before writing the response body. Once the body starts sending, the headers are locked. To check, look at the HasStarted property on Response.
- Do not take a Scoped service in the Middleware constructor. Make it a parameter of the InvokeAsync method.
- If you do not call next, answer yourself. For example, set the status code. Otherwise the user gets an empty answer.
- Do not do heavy work in a Middleware. This code runs for all requests. A 50 millisecond job makes the whole API slow.
- Pass the cancellation token. From the Middleware down to the database.
Common mistakes
| Mistake | Result | The right way |
|---|---|---|
| Authorization before authentication | A user with a valid token is seen as anonymous and is rejected. | First authentication, then authorization. |
| Error handling in the middle of the line | Errors from outer layers are not caught, and the internal error text reaches the user. | Error handling must be the first Middleware. |
| Taking a DbContext in the Middleware constructor | One DbContext is shared between all requests. | A parameter of the InvokeAsync method. |
| Changing a header after next | It throws an error, because the response has already started. | Set the header before next, or use the OnStarting method on Response. |
| Not calling next and not answering | The request ends silently and the user gets an empty answer. | Either call next, or write a status code and an answer. |
| Not passing the CancellationToken | Heavy queries keep running even after the user has left. | A CancellationToken parameter, passed all the way down. |
When to write a Middleware?
Good fit
- Work that all or most requests need.
- Work that is about HTTP: headers, logging, tracking ID, timing.
- Work that must reject the request before it reaches the endpoint.
Bad fit
- A business rule, like “maximum order amount”. Its place is in the domain.
- Work that is only for one endpoint. A Filter is enough.
- Something the framework already has, like authentication, CORS or Rate Limiting. Do not write it again yourself.
Summary in six lines
- Every request passes through a line of Middleware. The response comes back through the same line.
- Each Middleware can work before and after next, or end the request right there.
- The order is the order in Program.cs: errors, routing, authentication, authorization, endpoint.
- A Middleware class is created once. Take Scoped services in the InvokeAsync method.
- Set the headers before the response body starts.
- Pass the request cancellation token down to the database.