Global exception handling in a Web API is the safety net that catches unhandled exceptions from controllers, services, and filters, and converts them into appropriate ProblemDetails JSON responses. Without it, unhandled exceptions produce HTML error pages (from the developer exception page) or empty 500 responses — neither useful for Angular clients. ASP.NET Core 8’s IExceptionHandler interface provides the cleanest approach: register handlers for specific exception types in priority order, and the first handler that returns true wins.
IExceptionHandler Implementation
// ── Domain exception types ────────────────────────────────────────────────
public class NotFoundException(string entityName, object key)
: Exception($"{entityName} with key '{key}' was not found.") { }
public class ConflictException(string field, string message)
: Exception(message) { public string Field { get; } = field; }
public class ValidationException(Dictionary<string, string[]> errors)
: Exception("One or more validation errors occurred.")
{
public Dictionary<string, string[]> Errors { get; } = errors;
}
public class ForbiddenException(string message = "Access denied.") : Exception(message) { }
// ── Global exception handler ──────────────────────────────────────────────
public class GlobalExceptionHandler(
IProblemDetailsService problemDetailsService,
ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken ct)
{
var (statusCode, title, extensions) = exception switch
{
NotFoundException nfe =>
(404, nfe.Message, (Dictionary<string, object>?)null),
ConflictException ce =>
(409, ce.Message,
new Dictionary<string, object> { ["field"] = ce.Field }),
ValidationException ve =>
(400, "One or more validation errors occurred.",
new Dictionary<string, object> { ["errors"] = ve.Errors }),
ForbiddenException fe =>
(403, fe.Message, null),
UnauthorizedAccessException ue =>
(401, "Authentication required.", null),
OperationCanceledException =>
(499, "Request cancelled.", null),
_ =>
(500, "An unexpected error occurred.", null)
};
var correlationId = httpContext.Items["CorrelationId"]?.ToString()
?? httpContext.TraceIdentifier;
if (statusCode == 500)
logger.LogError(exception,
"[{CorrelationId}] Unhandled exception: {ExType}",
correlationId, exception.GetType().Name);
else
logger.LogWarning(exception,
"[{CorrelationId}] Domain exception: {ExType}",
correlationId, exception.GetType().Name);
httpContext.Response.StatusCode = statusCode;
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Instance = httpContext.Request.Path,
};
problemDetails.Extensions["correlationId"] = correlationId;
if (extensions is not null)
foreach (var (k, v) in extensions) problemDetails.Extensions[k] = v;
await problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = problemDetails,
Exception = exception,
});
return true; // exception handled — do not propagate
}
}
// ── Register ──────────────────────────────────────────────────────────────
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
IExceptionHandler.TryHandleAsync() returns a ValueTask<bool>. Returning true signals the exception has been handled — no further exception handlers run and the exception does not propagate. Returning false passes the exception to the next registered IExceptionHandler or to the default exception handler. This chaining mechanism lets you register multiple handlers in priority order: a specific handler for domain exceptions, a fallback handler for all others.ProblemDetails objects manually; they throw domain exceptions and let the handler convert them. Controllers become cleaner: throw NotFoundException in the service layer, and the handler returns a 404 response automatically.Common Mistakes
Mistake 1 — Not logging 500-level exceptions at Error level (observability gap)
❌ Wrong — all exceptions logged at the same level; genuine errors buried in warning noise.
✅ Correct — 4xx domain exceptions at Warning; 5xx unhandled exceptions at Error; enables alert thresholds.
Mistake 2 — Exposing exception type names in responses (information leakage)
❌ Wrong — response includes "type": "System.NullReferenceException"; reveals implementation.
✅ Correct — return only the ProblemDetails title and detail; log the full exception server-side.