By default an unhandled exception in an ASP.NET Core API produces either a blank 500 or, in development, a full stack trace. Neither is what a client should see. The framework has a complete mechanism for turning exceptions into consistent responses: exception-handling middleware, the standard ProblemDetails format, and an IExceptionHandler interface for mapping your own exceptions to status codes. Set up once, it removes try/catch from controllers entirely.
The minimal setup#
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler(); // catches anything thrown further down the pipeline
app.UseStatusCodePages(); // gives empty 404s and 405s a ProblemDetails body too
app.MapGet("/boom", () => { throw new InvalidOperationException("no"); });
app.Run();
With that, an exception becomes a 500 with a JSON body in the ProblemDetails format, no stack trace in production, and the exception logged. In development, the developer exception page still shows full detail, which is what you want there.
Order matters: UseExceptionHandler must be registered before anything that can throw, which in practice means first. Middleware registered above it is not covered.
What ProblemDetails looks like#
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00"
}
It is a standard (RFC 9457), so clients and tooling know how to read it. type identifies the kind of problem, title is a short human summary, status repeats the HTTP code, detail may carry a specific message, and you can add fields. The traceId lets a user quote something support can find in the logs.
Mapping your exceptions#
A domain exception such as “order not found” should become a 404, not a 500. Implement IExceptionHandler:
public sealed class NotFoundExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext context, Exception exception, CancellationToken ct)
{
if (exception is not NotFoundException nf) return false; // let the next handler try
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 404,
Title = "Not found",
Detail = nf.Message,
Instance = context.Request.Path,
}, ct);
return true; // handled
}
}
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<ConflictExceptionHandler>();
builder.Services.AddProblemDetails();
Handlers run in registration order; the first that returns true wins, and anything unhandled falls through to the default 500. One handler per exception family keeps them small. A single handler with a switch on exception type is the alternative and is fine for small APIs.
Validation errors#
Model-binding failures already produce a 400 with a ValidationProblemDetails body listing each invalid field. For controllers, the [ApiController] attribute does it automatically. For minimal APIs, validate explicitly and return the same shape:
app.MapPost("/orders", (CreateOrder cmd) =>
{
var errors = new Dictionary<string, string[]>();
if (cmd.Quantity <= 0) errors["quantity"] = ["Must be greater than zero."];
if (errors.Count > 0) return Results.ValidationProblem(errors);
return Results.Created("/orders/1", cmd);
});
Expected failures — validation, not found, conflict — are better returned directly than thrown. Exceptions are for the unexpected. A controller that throws NotFoundException on every missing row works, but Results.NotFound() says the same thing without the cost of an exception.
Adding context to every problem#
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Extensions["requestId"] = ctx.HttpContext.TraceIdentifier;
ctx.ProblemDetails.Extensions["timestamp"] = DateTimeOffset.UtcNow;
};
});
Runs for every ProblemDetails response, including the ones the framework generates. This is the place for a support reference or an environment tag.
Logging#
UseExceptionHandler logs unhandled exceptions at Error level with the full stack trace, so you do not need to log again in a handler. For exceptions you map to 4xx, log at Warning or Information — a not-found is not an error in the server. Structured logging with the trace id makes the client’s reference searchable:
logger.LogWarning(exception, "Not found: {Path} trace {TraceId}", context.Request.Path, context.TraceIdentifier);
Best practices, briefly#
- No try/catch in controllers or endpoints for the general case. Let the middleware handle it.
- Return results for expected outcomes; throw for unexpected ones.
- One ProblemDetails shape everywhere, including 404s from routing and 405s, via
UseStatusCodePages. - Map cancellation: an
OperationCanceledExceptionwhen the client disconnects should be a 499-style early return, not a logged 500. - Test the handlers: an integration test that hits an endpoint which throws and asserts on the ProblemDetails body.
Questions people ask#
Do I still need UseDeveloperExceptionPage?
It is added automatically in the Development environment by WebApplication.CreateBuilder. UseExceptionHandler is for every other environment; register both and the framework picks.
Can I use exception filters instead?
Filters work for MVC controllers only and run later in the pipeline. Middleware covers minimal APIs, controllers and everything else, so prefer it.
What status code for a business-rule violation?
Usually 400 for invalid input, 409 for a state conflict such as “already exists”, 422 if you want to distinguish semantic errors from malformed requests. Be consistent across the API.
Is ProblemDetails required?
No, but it is the standard, clients understand it, and the framework produces it for free. Inventing your own error shape gains nothing.
Where to go next#
- REST API error handling and status codes — the language-agnostic rules.
- C# and ASP.NET Core — the framework overview.
- CORS not working in ASP.NET Core — the other middleware-order problem.