Global Exception Middleware
The ErrorController + UseExceptionHandler("/Error") combo from ApiResponse & Controllers works, but every exception ends up generic 500s with no distinction. ASP.NET Core's IExceptionHandler interface lets you map specific exception types to specific status codes in one place, so controllers can stop wrapping every action in try/catch.
One handler, one switch
Middleware/GlobalExceptionHandler.cs
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) => _logger = logger;
public async ValueTask<bool> TryHandleAsync(
HttpContext context, Exception exception, CancellationToken cancellationToken)
{
var (statusCode, message) = exception switch
{
KeyNotFoundException => (StatusCodes.Status404NotFound, exception.Message),
AuthenticationException => (StatusCodes.Status401Unauthorized, exception.Message),
ArgumentException => (StatusCodes.Status400BadRequest, exception.Message),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred.")
};
if (statusCode == StatusCodes.Status500InternalServerError)
_logger.LogError(exception, "Unhandled exception");
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(
ApiResponse<object>.Fail(message), cancellationToken);
return true; // we handled it — don't let it bubble further
}
}
Registration
AddProblemDetails() is required alongside the handler even though this example doesn't use RFC 7807 problem details directly — it's what wires the exception-handling middleware pipeline up in .NET 8+.
Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
// ...
app.UseExceptionHandler(); // no "/Error" path needed — handled in-process
Controllers shrink
With exception types mapped centrally, a controller action becomes a straight line — throw a meaningful exception and let the middleware translate it.
ResourceController.cs — before vs after
// Before: try/catch in every action (see ApiResponse & Controllers)
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetAsync(Guid id)
{
try
{
var result = await _service.GetDetailAsync(id);
return Ok(ApiResponse<ResourceDto>.Ok(result));
}
catch (KeyNotFoundException ex)
{
return NotFound(ApiResponse<ResourceDto>.Fail(ex.Message));
}
}
// After: the service throws, the middleware maps it
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetAsync(Guid id)
{
var result = await _service.GetDetailAsync(id); // throws KeyNotFoundException on miss
return Ok(ApiResponse<ResourceDto>.Ok(result));
}
Trade-off: centralizing exception → status-code mapping is less flexible per-endpoint (you can't easily return a different status for the same exception type in two different controllers). For most CRUD APIs that consistency is exactly what you want; reach for the per-controller try/catch style instead when a handful of endpoints genuinely need bespoke error handling.