Last updated: October 18, 2026 · Tested with .NET 10 (SDK 10.0.401, ASP.NET Core 10.0.12), compared with ASP.NET Core 9.0.14, Windows 11
Short answer: Implement IExceptionHandler, register it with builder.Services.AddExceptionHandler<T>(), add builder.Services.AddProblemDetails() and call app.UseExceptionHandler(). Handlers run in registration order until one returns true, so put the catch-all last. I built three handlers (validation → 400, not found → 404, everything else → 500) on .NET 10 and tested them with curl. Four results you should know before you ship:
- Registering the global handler first turned every 404 into a 500.
- Browser requests (
Accept: text/html) got an empty 500 until I added a fallback. - The developer exception page never showed.
- .NET 10 stopped logging exceptions your handler handles; .NET 9 logged them.
Why IExceptionHandler Instead of Custom Middleware
Before .NET 8, global error handling meant writing a middleware with a try/catch or passing a lambda to UseExceptionHandler. IExceptionHandler (in Microsoft.AspNetCore.Diagnostics, since .NET 8) gives you small, DI-friendly classes instead, each responsible for one kind of exception. The built-in exception handler middleware calls them and takes care of the rest: clearing the response, keeping the original exception in IExceptionHandlerFeature, and logging. No NuGet package is needed.
The Setup
using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(o =>
o.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Instance = $"{ctx.HttpContext.Request.Method} {ctx.HttpContext.Request.Path}";
ctx.ProblemDetails.Extensions["traceId"] = Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
});
// Order matters: handlers run in registration order until one returns true.
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapGet("/products/{id:int}", (int id) =>
id == 42 ? Results.Ok(new { id, name = "Keyboard" }) : throw new NotFoundException($"Product {id} was not found."));
app.Run();
AddProblemDetails registers IProblemDetailsService, which the handlers use to write RFC 9457 problem JSON. CustomizeProblemDetails adds the request and a trace ID to every error response, so a user's error report can be matched to your logs. UseStatusCodePages makes plain 404s (unknown routes) return problem JSON too.
The Handlers
Each handler checks the exception type and returns false if it isn't its job. The middleware then tries the next one:
public sealed class NotFoundException(string message) : Exception(message);
public sealed class ValidationException(IDictionary<string, string[]> errors) : Exception("Validation failed")
{
public IDictionary<string, string[]> Errors { get; } = errors;
}
public sealed class NotFoundExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception exception, CancellationToken ct)
{
if (exception is not NotFoundException) return false;
http.Response.StatusCode = StatusCodes.Status404NotFound;
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = http,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Title = "Resource not found",
Detail = exception.Message,
Status = StatusCodes.Status404NotFound
}
});
}
}
The validation handler is the same shape, but writes a ValidationProblemDetails with the field errors and status 400. The catch-all handler logs and returns a generic 500. Its final version, with the browser fix explained below:
// Last resort: log once and return a generic 500 without exception details.
public sealed class GlobalExceptionHandler(IProblemDetailsService problemDetails, ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception exception, CancellationToken ct)
{
logger.LogError(exception, "Unhandled exception for {Method} {Path}", http.Request.Method, http.Request.Path);
http.Response.StatusCode = StatusCodes.Status500InternalServerError;
var problem = new ProblemDetails
{
Title = "An unexpected error occurred",
Status = StatusCodes.Status500InternalServerError
};
if (await problemDetails.TryWriteAsync(new ProblemDetailsContext { HttpContext = http, Exception = exception, ProblemDetails = problem }))
return true;
// TryWriteAsync returns false when the client doesn't accept JSON (e.g. a browser sending Accept: text/html).
// Write the problem anyway, so the response isn't an empty 500.
await http.Response.WriteAsJsonAsync(problem, options: null, contentType: "application/problem+json", cancellationToken: ct);
return true;
}
}
Real Responses
Production environment, curl -i against the running app (headers trimmed):
$ curl -i http://localhost:5190/products/7
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Resource not found","status":404,
"detail":"Product 7 was not found.","instance":"GET /products/7",
"traceId":"00-1f39f9d8795106faeef64238174a8860-a5df8bdd7757bcae-00"}
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"name":"","price":0}' http://localhost:5190/products
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,
"instance":"POST /products","errors":{"name":["Name is required."],"price":["Price must be greater than 0."]},
"traceId":"00-2b1e7853f4c53538b4eb9bc913a2ec30-2b533007b0bcdbb4-00"}
$ curl -i http://localhost:5190/boom
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An unexpected error occurred","status":500,
"instance":"GET /boom","traceId":"00-c9e8b88bd2a684ca8af7c6bd68b945f1-b615c563bfec5cae-00"}
$ curl -i http://localhost:5190/does-not-exist
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,
"instance":"GET /does-not-exist","traceId":"00-7ec7ef6f6e028bc3444cf2ad11e65c12-3864d615a854d060-00"}
The /boom endpoint throws InvalidOperationException("Connection string 'Db' has password=secret123"). None of that reaches the client. It appears only in the server log, from the catch-all handler:
fail: ErrorsApi.GlobalExceptionHandler[0]
Unhandled exception for GET /boom
System.InvalidOperationException: Connection string 'Db' has password=secret123
Never put exception.Message into Detail in a catch-all handler. Messages from libraries can contain connection strings, SQL and file paths. Only messages you wrote for users, like the NotFoundException above, belong in the response. More on what not to expose: API security best practices.
Gotcha 1: Registration Order Decides Everything
I swapped the order and registered GlobalExceptionHandler before NotFoundExceptionHandler:
$ curl -i http://localhost:5190/products/7
HTTP/1.1 500 Internal Server Error
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An unexpected error occurred","status":500,...}
The catch-all returns true for everything, so the 404 handler is never asked. Register specific handlers first and the catch-all last. Nothing warns you about this at startup.
Gotcha 2: Browsers Got an Empty 500
My first catch-all simply returned the result of TryWriteAsync. That works for API clients, which send Accept: application/json or */*. A browser sends Accept: text/html:
$ curl -i -H 'Accept: text/html' http://localhost:5190/boom
HTTP/1.1 500 Internal Server Error
Content-Length: 0
IProblemDetailsService.TryWriteAsync only writes when the client accepts JSON. Otherwise it returns false, the handler returns false, and the middleware falls back to an empty 500. It also logs the exception a second time as unhandled. With the fallback WriteAsJsonAsync shown in the final handler above:
$ curl -i -H 'Accept: text/html' http://localhost:5190/boom
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{"title":"An unexpected error occurred","status":500}
The fallback body skips CustomizeProblemDetails, so type, instance and traceId are missing. Add them to the object yourself if you need them there. If you serve HTML pages, render an error page here instead.
Gotcha 3: .NET 10 No Longer Logs Handled Exceptions
Same app, same requests (/products/7 and /boom), run on ASP.NET Core 9.0.14 and 10.0.12. The .NET 9 log:
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
An unhandled exception has occurred while executing the request.
ErrorsApi.NotFoundException: Product 7 was not found.
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
An unhandled exception has occurred while executing the request.
System.InvalidOperationException: Connection string 'Db' has password=secret123
fail: ErrorsApi.GlobalExceptionHandler[0]
Unhandled exception for GET /boom
System.InvalidOperationException: Connection string 'Db' has password=secret123
On .NET 10, only the last entry, from my own handler, appears. The middleware no longer logs (or emits diagnostics for) exceptions that an IExceptionHandler handled. The 404 now leaves no trace in the log at all. That's less noise, but if your alerting counted ExceptionHandlerMiddleware errors, those counts drop after the upgrade. .NET 10 adds ExceptionHandlerOptions.SuppressDiagnosticsCallback (I checked with reflection: it isn't on .NET 9) to restore the old behavior:
app.UseExceptionHandler(new ExceptionHandlerOptions { SuppressDiagnosticsCallback = _ => false });
With that line the .NET 10 app logged the 404 again (ExceptionHandlerMiddleware[1] ... NotFoundException: Product 7 was not found.). The better fix is usually to log in your handlers, as the catch-all above does, at the level each exception deserves. For structured logging, see Serilog in .NET.
Gotcha 4: The Developer Exception Page Never Appears
In the Development environment, the same /boom request returned the same generic problem JSON, not the developer exception page. WebApplication adds the developer page as the outermost middleware in Development. But UseExceptionHandler runs inside it, catches the exception first, and your handlers turn it into a normal response. If you want the stack trace page while developing, register the handler middleware only outside Development:
if (!app.Environment.IsDevelopment())
app.UseExceptionHandler();
That also means your handlers don't run in Development, so test them with the Production environment (ASPNETCORE_ENVIRONMENT=Production), as I did here.
Gotcha 5: Errors After the Response Has Started
An endpoint that writes and flushes part of the body, then throws:
$ curl -i http://localhost:5190/stream
HTTP/1.1 200 OK
Content-Type: text/plain
partial output...
warn: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[2]
The response has already started, the error handler will not be executed.
The client got a 200 and a truncated body. No handler can change a status code that's already been sent. For streaming endpoints, validate everything before writing the first byte.
Startup Errors You May Hit
UseExceptionHandler() with no AddProblemDetails() and no handler options stops the app at startup:
System.InvalidOperationException: An error occurred when configuring the exception handler middleware. Either the 'ExceptionHandlingPath' or the 'ExceptionHandler' property must be set in 'UseExceptionHandler()'. Alternatively, set one of the aforementioned properties in 'Startup.ConfigureServices' as follows: 'services.AddExceptionHandler(options => { ... });' or configure to generate a 'ProblemDetails' response in 'service.AddProblemDetails()'.
Handlers that inject IProblemDetailsService without AddProblemDetails() fail the same way, with a DI error:
System.InvalidOperationException: Unable to resolve service for type 'Microsoft.AspNetCore.Http.IProblemDetailsService' while attempting to activate 'ErrorsApi.ValidationExceptionHandler'.
Fix for both: call builder.Services.AddProblemDetails(). With AddProblemDetails() and no custom handlers at all, unhandled exceptions get a default problem response, "title":"An error occurred while processing your request.","status":500, which is a reasonable minimum for any API.
A Simpler Option for Status Codes Only: StatusCodeSelector
If all you need is "this exception type → that status code", ASP.NET Core 9 and later have ExceptionHandlerOptions.StatusCodeSelector:
app.UseExceptionHandler(new ExceptionHandlerOptions
{
StatusCodeSelector = ex => ex switch
{
NotFoundException => StatusCodes.Status404NotFound,
ValidationException => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status500InternalServerError
}
});
Tested on .NET 10 with no handlers registered: /products/7 returned 404 with the default "title":"Not Found" problem body. When handlers are registered too, a handler that returns true wins: the same request then got my handler's "title":"Resource not found". Use IExceptionHandler when the response body differs per exception, like the validation errors above, or when handlers need injected services.
Checklist
AddProblemDetails(), then specific handlers, then the catch-all, thenUseExceptionHandler().- Catch-all returns a generic message; no
exception.Messagein public responses. - A fallback write when
TryWriteAsyncreturnsfalse. - A trace ID in every error response, and the same ID in your logs.
- After upgrading to .NET 10, check alerts that counted
ExceptionHandlerMiddlewareerrors. - Test handlers with
ASPNETCORE_ENVIRONMENT=Production. - Validate streaming endpoints before writing the response.
FAQ
Do I need a NuGet package for IExceptionHandler?
No. It's in the ASP.NET Core shared framework (Microsoft.AspNetCore.Diagnostics) since .NET 8.
Can I register more than one IExceptionHandler?
Yes. They run in registration order, and the first one to return true wins.
Does IExceptionHandler work with controllers and minimal APIs?
Yes. It's middleware-level, so it catches exceptions from controllers, minimal API endpoints and other middleware registered after UseExceptionHandler. The endpoints in this article are minimal APIs.
Should I throw exceptions for validation and not-found?
It's a design choice. Exceptions keep endpoints short. Returning results (Results.NotFound(), Results.ValidationProblem()) is cheaper and more explicit. Either way, keep a catch-all handler for the exceptions you didn't plan for. For general exception design in C#, see C# exception handling best practices.
Conclusion
The setup is small: AddProblemDetails, a few IExceptionHandler classes, and UseExceptionHandler. The four things that bit me in testing are worth checking in your own app:
- handler order;
- empty 500s for browsers;
- the developer page disappearing;
- .NET 10 dropping the middleware's log line for handled exceptions.
References: Handle errors in ASP.NET Core · Handle errors in ASP.NET Core APIs · IExceptionHandler API reference · .NET 10 breaking change: exception diagnostics suppressed · RFC 9457: Problem Details for HTTP APIs
This article is part of the ASP.NET Core tutorials guide (APIs, security, observability and deployment).
Comments
Post a Comment