IExceptionHandler: Global Error Handling Done Right

Raise your hand if you’ve ever written an exception-handling middleware in ASP.NET Core that caught Exception, logged it, and returned a ProblemDetails response. Yeah, me too. We’ve all written that middleware. We’ve all copy-pasted it between projects. ASP.NET Core now has IExceptionHandler built in, and it’s cleaner, more composable, and actually designed for this.

Here’s a custom exception handler that turns known exceptions into proper Problem Details responses:

using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddExceptionHandler<AppExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.MapGet("/", () =>
{
throw new InvalidOperationException("Something went sideways!");
});
app.MapGet("/not-found", () =>
{
throw new NotFoundException("The droids you're looking for");
});
app.Run();
// Custom exception for demo purposes
class NotFoundException(string item)
: Exception($"Could not find: {item}");
// The exception handler: clean and focused
class AppExceptionHandler(ILogger<AppExceptionHandler> logger)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
logger.LogError(exception, "Unhandled exception occurred");
var (statusCode, title) = exception switch
{
NotFoundException => (StatusCodes.Status404NotFound, "Not Found"),
ArgumentException => (StatusCodes.Status400BadRequest, "Bad Request"),
_ => (StatusCodes.Status500InternalServerError, "Server Error")
};
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = exception.Message,
Instance = httpContext.Request.Path
}, cancellationToken);
return true; // We handled it
}
}

The beauty of IExceptionHandler is the return value. If TryHandleAsync returns true, the exception is considered handled and the pipeline stops. Return false, and the next registered handler gets a shot. This means you can chain multiple handlers (one for validation errors, one for auth failures, one as a catch-all, each focused on a single responsibility).

// Register multiple handlers: they execute in order
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<FallbackExceptionHandler>();

This integrates seamlessly with AddProblemDetails(), which ensures your error responses conform to RFC 9457. Your API consumers get consistent, well-structured error payloads. Your code stays clean. And you never have to write app.Use(async (ctx, next) => { try { ... } catch { ... } }) again. That middleware has earned its retirement.