In ASP.NET Core, individual controller actions should not contain try/catch blocks for domain errors โ that responsibility belongs to a centralised exception handler. A well-designed exception handling pipeline ensures that every unhandled exception is caught, logged, and converted to a structured HTTP response with the correct status code and a consistent ProblemDetails body. This keeps controllers thin, eliminates repetition, and gives you one place to tune error response formatting for your entire API.
ProblemDetails โ RFC 9457 Error Responses
// ProblemDetails is the .NET implementation of RFC 9457 (Problem Details for HTTP APIs)
// Standard error response shape:
// {
// "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
// "title": "Not Found",
// "status": 404,
// "detail": "Post '42' was not found.",
// "instance": "/api/posts/42",
// "traceId": "00-abc123-01"
// }
// Enable ProblemDetails in Program.cs
builder.Services.AddProblemDetails(); // enables built-in ProblemDetails support
// ASP.NET Core 8 also enables it for unhandled exceptions automatically
BadRequest(), NotFound(), or Forbid() from a controller, ASP.NET Core already formats the response as ProblemDetails when AddProblemDetails() is registered. The type field is a URI that identifies the error type, title is a short description, status is the HTTP status code, and detail is a human-readable explanation. Angular and other clients can rely on this consistent shape for error handling.traceId in your error responses โ it should match the trace ID in your application logs. When a user reports an error, they can provide the trace ID and you can look up the exact request in your logs. ASP.NET Core 8 with AddProblemDetails() automatically adds the trace ID from Activity.Current?.Id to all ProblemDetails responses. Log at Error level with the same trace ID, and debugging production errors becomes tractable.detail message that is appropriate for the caller to see. In development (ASPNETCORE_ENVIRONMENT=Development), you may include stack traces for developer tools, but always strip them in production using if (app.Environment.IsDevelopment()) checks.Global Exception Handler โ .NET 8 IExceptionHandler
// โโ IExceptionHandler โ the clean .NET 8 approach โโโโโโโโโโโโโโโโโโโโโโโโ
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
=> _logger = logger;
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(exception,
"Unhandled exception: {Message}", exception.Message);
var (statusCode, title) = exception switch
{
NotFoundException => (StatusCodes.Status404NotFound, "Not Found"),
ConflictException => (StatusCodes.Status409Conflict, "Conflict"),
ValidationException => (StatusCodes.Status422UnprocessableEntity, "Validation Error"),
ForbiddenException => (StatusCodes.Status403Forbidden, "Forbidden"),
AppException => (StatusCodes.Status400BadRequest, "Bad Request"),
_ => (StatusCodes.Status500InternalServerError, "Internal Server Error"),
};
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = exception is AppException
? exception.Message
: "An unexpected error occurred.",
Instance = httpContext.Request.Path,
};
// Add structured error details for ValidationException
if (exception is ValidationException validationEx)
problemDetails.Extensions["errors"] = validationEx.Errors;
// Add trace ID for log correlation
problemDetails.Extensions["traceId"] =
System.Diagnostics.Activity.Current?.Id ?? httpContext.TraceIdentifier;
httpContext.Response.StatusCode = statusCode;
httpContext.Response.ContentType = "application/problem+json";
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true; // true = exception handled; false = let next handler try
}
}
// โโ Registration in Program.cs โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
// ...
app.UseExceptionHandler(); // activates the IExceptionHandler pipeline
Controller Action โ No Try/Catch Needed
[ApiController]
[Route("api/posts")]
public class PostsController : ControllerBase
{
private readonly IPostService _service;
public PostsController(IPostService service) => _service = service;
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
// No try/catch โ NotFoundException propagates to GlobalExceptionHandler
var post = await _service.GetByIdAsync(id);
return Ok(post);
}
[HttpPost("{id:int}/publish")]
public async Task<IActionResult> Publish(int id)
{
// No try/catch โ NotFoundException, ConflictException, ForbiddenException
// are all handled globally and converted to the correct HTTP status
var published = await _service.PublishAsync(id, User.GetUserId());
return Ok(published);
}
}
// Result: clean, thin controllers with zero exception handling boilerplate
Common Mistakes
Mistake 1 โ Try/catch in every controller action
โ Wrong โ repetitive, error-prone, hard to maintain consistently:
public async Task<IActionResult> GetById(int id)
{
try { return Ok(await _service.GetById(id)); }
catch (NotFoundException) { return NotFound(); }
catch (Exception) { return StatusCode(500); }
}
โ Correct โ register GlobalExceptionHandler once; controllers stay clean.
Mistake 2 โ Returning stack traces in production error responses
โ Wrong โ exposes internal implementation details and potential security vulnerabilities.
โ Correct โ use generic messages in production; reserve detailed errors for development environment only.