Global Exception Handling — IExceptionHandler and ProblemDetails

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();
Note: 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.
Tip: Keep domain exception types thin — just a message and any data needed to generate the HTTP response. The global exception handler maps these to HTTP responses. This means controllers and services never need to construct 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.
Warning: Never include stack traces, exception class names, or inner exception messages in production responses. These reveal implementation details that attackers use for reconnaissance. Log them server-side with the correlation ID; return only the correlation ID and a generic message to the client. Verify by testing with an intentional exception in a staging environment and inspecting the response body — it should contain only the ProblemDetails shape without any server internals.

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.

🧠 Test Yourself

A service throws NotFoundException("Post", 42). No try/catch in the controller. What does the Angular client receive?