Global Exception Handling in ASP.NET Core

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
Note: ProblemDetails (RFC 9457) is the industry standard for HTTP API error responses. When you return 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.
Tip: Always include a 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.
Warning: Never return exception stack traces, internal error messages, or file paths in API responses โ€” these are information disclosure vulnerabilities. A stack trace reveals your implementation details, library versions, and potentially exploitable code paths. Only return the 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.

🧠 Test Yourself

A service throws NotFoundException("Post", 42). Trace the full path from the thrown exception to the HTTP response sent to the client.