Skip to main content

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/catch exposes 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.

Program.cs
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.

.NET 8 introduced the IExceptionHandler interface, which lets you split exception handling into separate classes and chain multiple handlers via DI.

Define Domain Exceptions

Exceptions/NotFoundException.cs
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

Infrastructure/GlobalExceptionHandler.cs
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)

Infrastructure/UnhandledExceptionHandler.cs
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

Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddExceptionHandler<UnhandledExceptionHandler>();
builder.Services.AddProblemDetails();

var app = builder.Build();

app.UseExceptionHandler();
Handler Chain

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.

Example error response
{
"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"
}
FieldDescription
typeURI identifying the error type
titleHuman-readable short summary
statusHTTP status code
detailError detail specific to this occurrence
instanceURI 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.

Services/OrderService.cs
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:

Controllers/OrdersController.cs
[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.

Program.cs
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler();
}

Best Practices Summary

PracticeWhy
Use IExceptionHandlerSeparation of concerns, testability, chainable handlers
Return Problem DetailsRFC-compliant; consistent contract for clients
Hide stack traces in productionReduces information leakage
Include traceIdCorrelates with distributed traces for incident investigation
Log domain exceptions as WarningNotFoundException is expected behavior, not an error
Always register a fallback handlerGuarantees no raw exception ever reaches the client