Every ASP.NET Core codebase eventually grows a monster. Sometimes it's a 200-line ExceptionHandlingMiddleware class with a switch statement mapping exception types to status codes. Sometimes it's a base controller with a try/catch in every action method. Either way, the result is the same: exception handling logic that's hard to test, impossible to extend, and tightly coupled to response formatting.
ASP.NET Core 8 introduced IExceptionHandler — a first-class interface that plugs directly into the existing exception handling middleware. It gives you a chain-of-responsibility pattern out of the box: register multiple handlers, each one deciding whether it can deal with the exception or should pass it along. No custom middleware classes, no reflection-based exception mappers, no try-catch ceremonies. And in .NET 10, the framework got smarter about diagnostics too, suppressing redundant logs for exceptions you've already handled.
If you're still wrapping your pipeline in a hand-rolled try/catch middleware, this is the upgrade path.
The interface
IExceptionHandler lives in Microsoft.AspNetCore.Diagnostics and has exactly one method:
public interface IExceptionHandler
{
ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken);
}
The contract is simple. Return true if you handled the exception and wrote a response. Return false to pass it to the next handler in the chain — or, if you're the last one, to the default middleware behaviour (error page, Problem Details fallback, or re-throw).
That ValueTask<bool> return type matters. Most handlers will do a quick type check and return false synchronously, so ValueTask avoids the allocation overhead of a Task on the hot path.
Registering handlers
Handlers are registered through dependency injection and invoked by the built-in UseExceptionHandler middleware:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
var app = builder.Build();
app.UseExceptionHandler();
app.MapControllers();
app.Run();
// IMPORTANT
You must call app.UseExceptionHandler() for the handlers to fire. Registering services without adding the middleware is a silent no-op — the framework won't warn you.
The middleware iterates through handlers in registration order. The first handler to return true from TryHandleAsync wins, and no further handlers execute. This makes ordering deliberate: specific handlers first, catch-all handler last.
Building a handler chain
A realistic application has different exception types that map to different HTTP responses. Rather than cramming all of that into one class, split it into focused handlers.
Handling domain exceptions
Start with the exceptions you control — validation failures, not-found lookups, authorisation violations:
public sealed class ValidationExceptionHandler(IProblemDetailsService problemDetailsService)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not ValidationException validationException)
return false;
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
var errors = validationException.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray());
await problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails =
{
Title = "Validation failed",
Status = StatusCodes.Status400BadRequest,
Detail = "One or more validation errors occurred.",
Extensions = { ["errors"] = errors }
}
});
return true;
}
}
public sealed class NotFoundExceptionHandler(IProblemDetailsService problemDetailsService)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not EntityNotFoundException notFound)
return false;
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails =
{
Title = "Resource not found",
Status = StatusCodes.Status404NotFound,
Detail = notFound.Message
}
});
return true;
}
}
Each handler has a single responsibility: check the exception type, write the response, and return true. If the type doesn't match, return false and let the chain continue.
The catch-all handler
The last handler in the chain should deal with everything that slipped through:
public sealed class GlobalExceptionHandler(
IProblemDetailsService problemDetailsService,
ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
logger.LogError(exception, "Unhandled exception for {Method} {Path}",
httpContext.Request.Method,
httpContext.Request.Path);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails =
{
Title = "Internal server error",
Status = StatusCodes.Status500InternalServerError,
Detail = "An unexpected error occurred. Please try again later."
}
});
return true;
}
}
// WARNING
Never leak exception details into the Problem Details Detail field in production. An internal server error response should tell the client that something went wrong, not what went wrong. Log the exception for your team; return a generic message for the caller.
Integration with Problem Details
The handlers above use IProblemDetailsService to write RFC 9457 responses. This service is available when you call builder.Services.AddProblemDetails() and ensures every error response follows the same structure — type, title, status, detail, and optional extensions.
You can customise the defaults globally:
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Instance =
$"{context.HttpContext.Request.Method} {context.HttpContext.Request.Path}";
context.ProblemDetails.Extensions["traceId"] =
context.HttpContext.TraceIdentifier;
};
});
This adds the request method, path, and trace ID to every Problem Details response — including those written by your exception handlers. Clients can include the traceId in support tickets, and you can correlate it with your structured logs.
If you don't need Problem Details — perhaps you're returning a custom error envelope — you can write directly to the response:
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsJsonAsync(
new { error = "Validation failed", details = errors },
cancellationToken);
return true;
But IProblemDetailsService is the better default. It respects content negotiation, handles the Accept header, and keeps your error format consistent with what the framework produces for non-exception errors like 404s from routing.
Controlling diagnostics in .NET 10
Before .NET 10, the exception handling middleware always emitted diagnostics — an UnhandledException log entry, an EventSource event, and an error.type tag on the http.server.request.duration metric — regardless of whether an IExceptionHandler had dealt with the exception. This meant that handled exceptions (validation failures, not-found lookups) polluted your logs and metrics alongside genuinely unexpected errors.
.NET 10 changed the default: when TryHandleAsync returns true, diagnostics are suppressed. Your ValidationExceptionHandler handles a bad request? No UnhandledException log entry. Your dashboard shows only the errors that actually surprised you.
If you need to bring back the old behaviour — perhaps you want metrics on every exception regardless — use SuppressDiagnosticsCallback:
app.UseExceptionHandler(new ExceptionHandlerOptions
{
SuppressDiagnosticsCallback = _ => false
});
You can also be selective. Suppress diagnostics for expected domain exceptions but emit them for everything else:
app.UseExceptionHandler(new ExceptionHandlerOptions
{
SuppressDiagnosticsCallback = context =>
context.Exception is ValidationException
or EntityNotFoundException
});
The callback receives an ExceptionHandlerFeature that gives you access to the exception, the original path, and the endpoint — enough context to make informed decisions about what deserves a log line.
// TIP
If you're migrating from .NET 9 to .NET 10 and you rely on exception metrics for alerting, audit your dashboards. Handled exceptions that previously triggered alerts will now be silent by default.
Testing handlers
Because handlers are plain classes with constructor-injected dependencies, they're straightforward to unit test:
[Fact]
public async Task Returns_400_for_validation_exception()
{
var handler = new ValidationExceptionHandler(new MockProblemDetailsService());
var httpContext = new DefaultHttpContext();
var exception = new ValidationException(
[
new ValidationFailure("Email", "Email is required")
]);
var result = await handler.TryHandleAsync(
httpContext, exception, CancellationToken.None);
Assert.True(result);
Assert.Equal(StatusCodes.Status400BadRequest, httpContext.Response.StatusCode);
}
[Fact]
public async Task Returns_false_for_non_validation_exception()
{
var handler = new ValidationExceptionHandler(new MockProblemDetailsService());
var httpContext = new DefaultHttpContext();
var result = await handler.TryHandleAsync(
httpContext, new InvalidOperationException(), CancellationToken.None);
Assert.False(result);
}
Compare this to testing custom middleware, where you need to construct a RequestDelegate, build a full HttpContext, invoke InvokeAsync, and assert against the response stream. The IExceptionHandler approach reduces the test surface to a single method call with a clear return value.
Common pitfalls
Forgetting UseExceptionHandler()
Registering handlers with AddExceptionHandler<T>() does nothing on its own. The handlers only execute when the exception handling middleware is in the pipeline. If you see exceptions bubbling up unhandled, check that app.UseExceptionHandler() is called before app.MapControllers() or app.MapEndpoints().
Injecting scoped services into a singleton handler
IExceptionHandler implementations are registered as singletons. If you inject a scoped service — like a DbContext — directly through the constructor, you'll get a captive dependency that outlives its intended scope.
// Bad — DbContext is scoped, handler is singleton
public sealed class AuditExceptionHandler(AppDbContext db) : IExceptionHandler { ... }
Instead, resolve scoped services from the HttpContext:
// Good — resolve from the request's service scope
public sealed class AuditExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
var db = httpContext.RequestServices.GetRequiredService<AppDbContext>();
// ...
}
}
Wrong handler ordering
If your catch-all GlobalExceptionHandler is registered first, it will handle everything — and your specific handlers for validation errors, not-found exceptions, and the like will never execute. Always register from most specific to least specific.
Swallowing exceptions silently
Returning true without logging or writing a response means the client gets a blank 200 OK. Always set the status code and write a response body before returning true. If you're only logging and want the default behaviour to continue, return false.
Double-writing the response
If your handler writes to httpContext.Response and then calls problemDetailsService.WriteAsync, you'll get a corrupted response or an InvalidOperationException because the response has already started. Pick one approach per handler — either write directly or use the Problem Details service, not both.
Summary
IExceptionHandleris a single-method interface that plugs into the existing exception handling middleware — no custom middleware needed.- Register multiple handlers with
AddExceptionHandler<T>(). They execute in registration order; the first to returntruewins. - Use
IProblemDetailsServicefor consistent RFC 9457 responses across all error types. - In .NET 10, diagnostics are suppressed by default for handled exceptions. Use
SuppressDiagnosticsCallbackto control this per-exception. - Handlers are singletons — resolve scoped services from
HttpContext.RequestServices, not the constructor. - Order matters: specific handlers first, catch-all last.