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 purposesclass NotFoundException(string item) : Exception($"Could not find: {item}");
// The exception handler: clean and focusedclass 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 orderbuilder.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.