Global Exception Handling
In production Web APIs, unhandled exceptions must never leak raw stack traces or bare 500 errors to clients. Global Exception Handling provides a centralized mechanism to catch all exceptions and return consistent error responses.
Why Global Exception Handling?
Sprinkling try/catch blocks across every controller and service leads to:
- Duplicate code: the same error-handling logic repeated everywhere
- Inconsistent responses: error shapes that differ by endpoint
- Coverage gaps: one missed
try/catchexposes a raw exception
A global handler solves all three at once.
Approach 1: Exception Handling Middleware (UseExceptionHandler)
The simplest built-in approach uses ASP.NET Core middleware.
var app = builder.Build();
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionFeature?.Error;
context.Response.ContentType = "application/problem+json";
context.Response.StatusCode = exception switch
{
NotFoundException => StatusCodes.Status404NotFound,
ValidationException => StatusCodes.Status422UnprocessableEntity,
UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status500InternalServerError
};
var problem = new ProblemDetails
{
Status = context.Response.StatusCode,
Title = GetTitle(exception),
Detail = exception?.Message,
Instance = context.Request.Path
};
await context.Response.WriteAsJsonAsync(problem);
});
});
This works but tends to concentrate all exception-handling logic in Program.cs.
Approach 2: IExceptionHandler (.NET 8+) — Recommended
.NET 8 introduced the IExceptionHandler interface, which lets you split exception handling into separate classes and chain multiple handlers via DI.
Define Domain Exceptions
public sealed class NotFoundException(string message) : Exception(message);
public sealed class ValidationException(string message, IEnumerable<string> errors)
: Exception(message)
{
public IEnumerable<string> Errors { get; } = errors;
}
Domain Exception Handler
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var (statusCode, title) = exception switch
{
NotFoundException => (StatusCodes.Status404NotFound, "Resource Not Found"),
ValidationException => (StatusCodes.Status422UnprocessableEntity, "Validation Failed"),
UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "Unauthorized"),
_ => (0, null)
};
if (statusCode == 0)
{
return false; // pass to the next handler
}
logger.LogWarning(exception, "Domain exception occurred: {ExceptionType}", exception.GetType().Name);
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = exception.Message,
Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}"
};
if (exception is ValidationException validationEx)
{
problemDetails.Extensions["errors"] = validationEx.Errors;
}
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
Fallback Handler (Unexpected Exceptions)
public sealed class UnhandledExceptionHandler(ILogger<UnhandledExceptionHandler> logger)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
logger.LogError(
exception,
"Unhandled exception. TraceId: {TraceId}",
httpContext.TraceIdentifier);
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An unexpected error occurred.",
Detail = "Please try again later or contact support.",
Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}"
};
problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier;
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
Registering with DI
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddExceptionHandler<UnhandledExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
Handlers are invoked in registration order. Returning false from TryHandleAsync passes control to the next handler. Register domain-specific handlers first, with the fallback last.
Problem Details (RFC 9457)
Error response shape is standardized by RFC 9457 (formerly RFC 7807). The ProblemDetails class is compliant out of the box.
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Resource Not Found",
"status": 404,
"detail": "Order with ID '42' was not found.",
"instance": "GET /api/orders/42",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
| Field | Description |
|---|---|
type | URI identifying the error type |
title | Human-readable short summary |
status | HTTP status code |
detail | Error detail specific to this occurrence |
instance | URI of the request that caused the error |
Use extensions to attach additional context (validation errors, trace IDs, etc.).
Throwing Domain Exceptions from the Service Layer
With the global handler in place, services simply throw the appropriate exception and stay focused on business logic.
public class OrderService(IOrderRepository repository)
{
public async Task<OrderDto> GetOrderAsync(int orderId, CancellationToken ct)
{
var order = await repository.FindAsync(orderId, ct);
if (order is null)
throw new NotFoundException($"Order with ID '{orderId}' was not found.");
return order.ToDto();
}
public async Task CreateOrderAsync(CreateOrderRequest request, CancellationToken ct)
{
var errors = new List<string>();
if (request.Quantity <= 0)
errors.Add("Quantity must be greater than zero.");
if (string.IsNullOrWhiteSpace(request.ProductCode))
errors.Add("ProductCode is required.");
if (errors.Count > 0)
throw new ValidationException("Invalid order request.", errors);
await repository.CreateAsync(request.ToEntity(), ct);
}
}
Controllers remain try/catch-free:
[ApiController]
[Route("api/[controller]")]
public class OrdersController(OrderService orderService) : ControllerBase
{
[HttpGet("{id:int}")]
public async Task<IActionResult> GetOrder(int id, CancellationToken ct)
{
var order = await orderService.GetOrderAsync(id, ct);
return Ok(order);
}
}
Development vs. Production
Show full stack traces in development; hide them in production.
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler();
}
Best Practices Summary
| Practice | Why |
|---|---|
Use IExceptionHandler | Separation of concerns, testability, chainable handlers |
| Return Problem Details | RFC-compliant; consistent contract for clients |
| Hide stack traces in production | Reduces information leakage |
Include traceId | Correlates with distributed traces for incident investigation |
| Log domain exceptions as Warning | NotFoundException is expected behavior, not an error |
| Always register a fallback handler | Guarantees no raw exception ever reaches the client |