Global Error Handling in C# Minimal APIs
A web API that throws an unhandled exception will, by default, return the kind of error page that helps a developer debug locally and helps a stranger map your call stack. Line numbers, type names, and the path to the source file all flow back to whoever made the request. Catching every error at the endpoint where it might happen is the right approach, but it only works as far as the next forgotten try/catch. A global handler is the safety net that catches what the endpoint missed.
In his video "Global Error Handling in C# Minimal APIs," Tim Corey builds a small minimal API with a deliberately broken endpoint, demonstrates the developer error page that gets returned without protection, and then wires up app.UseExceptionHandler to intercept any uncaught exception and respond with a generic 500. He also reinforces why endpoint-level handling stays the preferred path: the global handler is the fallback, not the strategy. Anyone shipping a minimal API who wants to be sure no stack trace ever leaves the server will find the middleware setup and the design rationale around it below.
Building a Minimal API with a Broken Endpoint
[1:08 - 3:01] Tim starts with a fresh .NET 8 ASP.NET Core Web API project named ErrorDemoApp. The project template options stay close to defaults: HTTPS on, OpenAPI on, no authentication, top-level statements left enabled, and the controllers checkbox unchecked since this is a minimal API. The generated Program.cs keeps Swagger, but the weather forecast sample endpoint and its record get deleted so the file shows only the basics.
In place of the sample, he adds a single endpoint at /demo whose entire purpose is to fail:
app.MapGet("/demo", () =>
{
throw new Exception("This is a demo exception");
});app.MapGet("/demo", () =>
{
throw new Exception("This is a demo exception");
});Running the project with Ctrl+F5 (start without debugging) keeps the Visual Studio debugger from intercepting the throw, so the failure surfaces the way it would for an actual HTTP caller. Swagger opens, the /demo endpoint is the only one available, and executing it returns a 500 response. The response body holds the exception type, the message, and a reference to Program.cs line 18.
Why the Default Error Page Leaks Implementation Details
[3:01 - 5:00] Hitting /demo directly in the browser (without the ?message= Swagger wrapper) shows the developer exception page rather than the JSON response. The page renders the exception name, the message, the file path and line number where the throw occurred, the raw exception details, and the stack frames above the throw. For a developer working locally this is gold. For anyone else, it is a free map of the codebase.
Tim's point lands without dressing it up: this page exists to help developers, and it should never reach end users. The fact that it sometimes does is the reason a global handler matters. Even teams that diligently wrap every endpoint in exception handling eventually miss one, and the cost of missing one is the entire stack trace going to whoever asked.
Catching Errors at the Endpoint First
[5:00 - 6:30] Before installing the global handler, Tim wraps the demo endpoint in a try/catch to illustrate the preferred path. The handler returns Results.BadRequest(ex.Message) for any thrown exception:
app.MapGet("/demo", () =>
{
try
{
throw new Exception("This is a demo exception");
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});app.MapGet("/demo", () =>
{
try
{
throw new Exception("This is a demo exception");
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});The result is a 400 carrying the message string only. No stack trace, no file path, no line number. Whether the message itself should be exposed depends on the application; for a public API, even the message can leak more than the team wants, in which case the handler substitutes a generic string. The local catch gives the endpoint full control over what the caller sees, including the choice to return a more specific status code than 500 when the failure mode is actually known.
What this pattern cannot do is catch what the endpoint forgot to wrap. Any new code path, any rethrow from a deeper layer, any Task that throws on a separate thread, all bypass the endpoint's try/catch. That is the gap the middleware fills.
Wiring Up the UseExceptionHandler Middleware
[6:30 - 10:00] Just below the app.UseHttpsRedirection() line, the handler gets registered with app.UseExceptionHandler. The overload that takes a builder action exposes the underlying pipeline, which lets the handler set the response shape explicitly:
app.UseExceptionHandler(appError =>
{
appError.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var contextFeature = context.Features.Get<IExceptionHandlerFeature>();
if (contextFeature is not null)
{
Console.WriteLine($"Error: {contextFeature.Error}");
}
await context.Response.WriteAsJsonAsync(new
{
StatusCode = context.Response.StatusCode,
Message = "Internal Server Error"
});
});
});app.UseExceptionHandler(appError =>
{
appError.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var contextFeature = context.Features.Get<IExceptionHandlerFeature>();
if (contextFeature is not null)
{
Console.WriteLine($"Error: {contextFeature.Error}");
}
await context.Response.WriteAsJsonAsync(new
{
StatusCode = context.Response.StatusCode,
Message = "Internal Server Error"
});
});
});A few choices in that block matter. Forcing the status code to 500 means the caller cannot infer anything from the response number; whatever the inner exception type was, the surface looks the same. Forcing the content type to application/json matches the rest of the API's responses, which keeps clients on a single parser. The IExceptionHandlerFeature exposes the original exception so a real handler can log it; Tim uses Console.WriteLine here as a stand-in for whatever logger the project would actually carry.
The final WriteAsJsonAsync call returns an anonymous object with the status code and the generic message. The body says nothing about what failed beyond the fact that something did, which is the point. Internal diagnostics belong in the log, not in the response.
Testing the Handled and Unhandled Paths
[10:00 - 13:14] With the try/catch still in place, the endpoint runs the local path: Swagger shows a 400 carrying "This is a demo exception". The middleware never sees the throw because the catch block resolves it first. This is the design Tim wants by default: local handlers do their job, and the global handler is dormant.
Removing the try/catch and running again exercises the fallback. The same request now returns a 500 with the JSON body { "statusCode": 500, "message": "Internal Server Error" }. Nothing in the response reveals where the exception was thrown or what type it was. The Visual Studio console window, however, shows the original exception text logged through the Console.WriteLine placeholder, including the file path and line number. The full diagnostic stays where developers can read it; the response stays where it cannot leak.
This pattern carries through to a minimal API with validation middleware, custom auth, or any other pipeline component. The exception handler sits early in the pipeline and catches whatever propagates up from a later stage.
Wrapping Up: Defense in Depth
[13:14 - 13:30] Local handling and global handling are not alternatives; they are layers. The local handler gives the endpoint the chance to respond meaningfully when the failure mode is known. The global handler ensures that anything the local layer missed produces a response that is consistent, generic, and safe. Controller-based APIs use the same idea with minor syntax adjustments, but the minimal API form is the one worth getting comfortable with first because the surface area is small enough to see the whole picture in one file.
Conclusion
[13:14 - 13:30] Setting up a global error handler in a minimal API is three steps: register UseExceptionHandler early in the pipeline, set the response status and content type inside the handler, and write a deliberately generic body so no implementation details escape. Pair that with local try/catch blocks around the code paths most likely to fail, and you have a defense-in-depth model where the global handler is the safety net rather than the strategy.
Example Tip: When the handler calls into a real logger, pass the entire exception object (not just the message) so the structured logging pipeline captures the type, stack, and any inner exceptions. A logger like Serilog will preserve all of that as queryable properties, which means the alert that fires for a production 500 carries enough context to reproduce locally without anyone re-running the request.
Watch full video on his YouTube Channel and gain more insights on building production-ready minimal APIs in the 10-Minute Training series.

