The Dependency Injection post covered the first half of Program.cs, everything on builder.Services, the registry that gets built before the app starts. This post covers the second half: everything after builder.Build(), the middleware pipeline. Lines like app.UseAuthentication() and app.MapControllers() get copied between projects constantly, usually without anyone being sure why they sit in the order they do. The Product API from earlier posts is the working example throughout.
What Middleware Actually Is
Middleware is a component that sits in the path of every HTTP request. Each one receives the request, can do some work, and then either passes the request on to the next component or stops it right there and responds itself. Together, the chain of middleware is called the request pipeline.
Think of airport security. Before you reach your gate, you pass through a series of checkpoints, passport control, a baggage scan, a boarding pass check. Each checkpoint inspects you, and each one makes a decision: wave you through to the next checkpoint, or stop you right there. If passport control rejects you, you never reach the baggage scan at all. The gate at the end is the endpoint, your controller action, and it only ever sees passengers who cleared every checkpoint before it.
The Request Goes In, and the Response Comes Back Out
A middleware component doesn't just run once. It gets a next delegate representing the rest of the pipeline. Code written before calling next() runs on the way in; code written after runs on the way back out, once everything further down the pipeline has finished.
var app = builder.Build();
app.Use(async (context, next) =>
{
Console.WriteLine("1 - request going in");
await next();
Console.WriteLine("4 - response coming back out");
});
app.Use(async (context, next) =>
{
Console.WriteLine("2 - request going in");
await next();
Console.WriteLine("3 - response coming back out");
});
app.MapGet("/", () => "Hello");
// Output for one request, in order:
// 1 - request going in
// 2 - request going in
// (the endpoint runs here)
// 3 - response coming back out
// 4 - response coming back out
The first middleware registered is the first one the request hits, and the last one the response passes through. In the airport picture, that's the passenger leaving through the same checkpoints in reverse order on the way back out.
Short-Circuiting: Stopping the Request Early
If a middleware doesn't call next(), nothing after it runs. The request stops there, and that middleware writes the response itself. This is how authentication, rate limiting, and API key checks reject a request before it ever reaches a controller.
// A simple API key check. The expected key comes from
// configuration - never hardcoded in the class, the same
// rule from the CRUD post
var expectedKey = app.Configuration["ApiKey"];
app.Use(async (context, next) =>
{
if (!context.Request.Headers.TryGetValue("X-Api-Key", out var providedKey)
|| providedKey != expectedKey)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("Missing or invalid API key.");
return; // no next() call - the pipeline stops here
}
await next();
});
Use vs Run vs Map
Three methods add to the pipeline, and they do genuinely different jobs.
app.Use adds middleware that receives a next delegate. It can do work, call next() to continue, or skip next() to short-circuit. It's the most common choice.
app.Run adds terminal middleware, it has no next delegate at all, so whatever reaches it ends there, and it's used for the very last step of a pipeline.
app.Map branches the pipeline, requests whose path starts with the given path go down the branch, and everything else continues on the main pipeline.
// Map branches off a separate mini-pipeline for /health
app.Map("/health", branch =>
{
branch.Run(async context =>
await context.Response.WriteAsync("Healthy"));
});
// Run at the end of the main pipeline - anything that
// reaches this point goes no further
app.Run(async context =>
await context.Response.WriteAsync("End of the line."));
In the airport picture: Use is a normal checkpoint, Run is the final gate, and Map is a separate lane, say crew-only, that splits off before the main queue.
Order Matters: It Is the Behavior
Middleware runs in exactly the order it's registered, so reordering lines changes what the application actually does. A typical, correctly ordered pipeline for an API looks like this.
app.UseExceptionHandler("/error"); // 1. catch errors from everything below
app.UseHttpsRedirection(); // 2. redirect HTTP to HTTPS
app.UseStaticFiles(); // 3. serve static files early
app.UseRouting(); // 4. work out which endpoint matches
app.UseCors(); // 5. cross-origin rules
app.UseAuthentication(); // 6. WHO is calling?
app.UseAuthorization(); // 7. are they ALLOWED to do this?
app.MapControllers(); // 8. run the matched endpoint
Why the order is what it is.
- Exception handling goes first, because an exception handler only catches exceptions thrown by middleware registered after it, put it last and it catches almost nothing, put it first and it wraps the entire pipeline.
- Authentication goes before authorization, because authorization decides whether a caller is allowed to do something, and that decision depends on knowing who the caller is, which is authentication's job, the same distinction covered in the Authentication vs Authorization post on this blog.
- Routing goes before the middleware that needs to know which endpoint was matched, authorization, for example, can read metadata from the matched endpoint, like an Authorize attribute, which only exists once routing has run.
This is the practical meaning of "order matters": the pipeline isn't a list of independent features, it's a sequence where each step relies on what the earlier steps already established.
Writing Custom Middleware: A Request Timing Example
Inline app.Use lambdas are fine for small things, but anything non-trivial belongs in its own class. A middleware class takes a RequestDelegate, the next step, in its constructor, and exposes an InvokeAsync method that receives the HttpContext.
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
public RequestTimingMiddleware(
RequestDelegate next,
ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
await _next(context); // everything else runs here
stopwatch.Stop();
_logger.LogInformation(
"{Method} {Path} responded {StatusCode} in {ElapsedMs} ms",
context.Request.Method,
context.Request.Path,
context.Response.StatusCode,
stopwatch.ElapsedMilliseconds);
}
}
// Registered in Program.cs
app.UseMiddleware<RequestTimingMiddleware>();
This uses the before-and-after structure directly: the stopwatch starts on the way in, and the log line is written on the way back out, once the status code is actually known. Note what gets logged, method, path, status, duration. Not headers, not tokens, nothing sensitive, which is the logging lesson from the CRUD post applied to a real piece of code.
A Lifetime Gotcha That Connects Back to the DI Post
A conventional middleware class is constructed once, when the application starts, and that single instance handles every request for the app's entire lifetime. That makes it behave like a Singleton, which means anything injected through its constructor is effectively captured for the whole app lifetime too.
// WRONG - a Scoped service injected into the constructor
// of a middleware that lives for the whole app lifetime.
// This is the captive dependency problem again.
public class AuditMiddleware
{
private readonly RequestDelegate _next;
private readonly IProductService _productService; // Scoped!
public AuditMiddleware(RequestDelegate next, IProductService productService)
{
_next = next;
_productService = productService;
}
// ...
}
// RIGHT - Scoped services go in the InvokeAsync parameters.
// They are resolved fresh, per request, by the DI container.
public class AuditMiddleware
{
private readonly RequestDelegate _next;
public AuditMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(
HttpContext context,
IProductService productService) // resolved per request
{
// use productService safely here
await _next(context);
}
}
The rule: constructor parameters on a middleware should be things safe to hold for the app's whole lifetime, like ILogger of T or configuration. Anything Scoped goes on InvokeAsync. There is also a factory-based alternative, IMiddleware, which is registered in DI and activated per request, a reasonable option when a middleware genuinely needs several Scoped dependencies.
Global Exception Handling: The Proper Fix From the CRUD Post
The CRUD post flagged two exception problems: errors swallowed silently, and ex.ToString() returned directly to the caller, leaking the stack trace. Handling errors in a try/catch inside every controller action doesn't scale, each one handles it a little differently, and one of them will eventually get it wrong. A single exception-handling middleware, registered first, fixes this in one place.
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;
public GlobalExceptionMiddleware(
RequestDelegate next,
ILogger<GlobalExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
// Full detail, including the stack trace, goes to the
// LOGS - visible to the team, never to the caller
_logger.LogError(ex,
"Unhandled exception for {Method} {Path}",
context.Request.Method,
context.Request.Path);
// If the response has already started, headers are
// already sent and a new status code can't be set
if (context.Response.HasStarted)
{
throw;
}
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
var problem = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An unexpected error occurred."
};
// Generic, safe message to the caller - no exception
// text, no stack trace, no internal paths
await context.Response.WriteAsJsonAsync(problem);
}
}
}
// Program.cs - registered FIRST, so it wraps everything
app.UseMiddleware<GlobalExceptionMiddleware>();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Because it's registered first, it wraps the entire pipeline: an exception thrown from authentication, a controller, a service, or the database layer all land in the same catch block. This is the structural version of the fix from the CRUD post, log the full exception internally, return only a generic message to the caller. ASP.NET Core also ships built-in options for this, app.UseExceptionHandler, and in .NET 8 and later the IExceptionHandler interface, worth knowing exist, but writing it by hand once is the clearest way to see what they're actually doing.
Problem Scenario and Solving Strategy
The problem: every controller action in the Products API has its own try/catch. Some return ex.ToString() to the caller, some swallow the error and return 200, some log and some don't. Nobody can say how long requests take, and a recent outage took hours to diagnose because the errors weren't logged consistently.
THE STRATEGY, STEP BY STEP:
Recognize this is a cross-cutting concern - error handling and timing apply to EVERY request, so they don't belong repeated inside individual controller actions
Write a GlobalExceptionMiddleware (above) that logs the full exception internally and returns a generic ProblemDetails response - and register it FIRST in the pipeline, so it wraps everything after it
Remove the per-action try/catch blocks that only existed to handle unexpected errors - the middleware now covers them, consistently
Write a RequestTimingMiddleware (above) that logs method, path, status, and duration for every request - never headers or tokens
Register both before authentication, so even rejected requests (401s) get timed and any exception thrown during authentication is still caught
Keep constructor parameters on both middleware limited to singleton-safe services (ILogger), and put any Scoped dependency on InvokeAsync, so the lifetime rule from the DI post isn't broken
The next outage now shows up in the logs with a full stack trace and the exact request that triggered it, while callers only ever see a generic 500
Key Lessons
Middleware is a chain of components each request passes through, each one can do work, pass the request on, or stop it and respond itself.
Code before await next() runs on the way in; code after it runs on the way back out, in reverse order, which is how a timing middleware can know the final status code.
Not calling next() short-circuits the pipeline, which is how authentication and API key checks reject requests before they reach a controller.
Use adds middleware that can continue or stop, Run is terminal with no next, and Map branches the pipeline for a specific path.
Order is the behavior: an exception handler only catches what's registered after it, and authentication has to come before authorization because authorization depends on knowing who the caller is.
A conventional middleware class is constructed once for the app's lifetime, so Scoped services belong on InvokeAsync, not in the constructor, the same captive dependency problem from the DI post.
A global exception middleware, registered first, replaces scattered try/catch blocks and fixes the leaked stack trace problem from the CRUD post in a single place.
Summary
The DI post explained the first half of Program.cs: a registry of services, built before the app starts. This post covers the second half: a pipeline of middleware, where every request travels in through each component and the response travels back out in reverse. Each component can pass the request along or stop it, which is how authentication rejections and API key checks work. Order isn't cosmetic, an exception handler only protects what comes after it, and authorization needs authentication to have already run. The two halves connect directly, too: a middleware instance lives for the entire app, so the lifetime rules from the DI post apply to it, and Scoped services belong on InvokeAsync. And the exception-handling lessons from the CRUD post finally get a proper home: one middleware, registered first, logging everything internally and returning only a generic error to the caller.
More from TechStack Blog: C# / .NET: https://www.techstackblog.com/category.html?cat=csharp
CS Fundamentals: https://www.techstackblog.com/category.html?cat=cs-fundamentals
Top comments (0)