Global Exception Handling in ASP.NET Core – A Complete Guide with IExceptionHandler

Unhandled exceptions are one of the fastest ways to break a production API. A single uncaught error can leak a full stack trace to a client, return an inconsistent error shape from one endpoint to the next, or crash a request pipeline that should have failed gracefully instead. If you’ve ever debugged a support ticket that simply said “the app returned an error,” you already know why centralized exception handling isn’t optional — it’s foundational.

This guide walks through how global exception handling works in modern ASP.NET Core, why the IExceptionHandler interface introduced in .NET 8 has become the recommended approach, and how to implement it the right way — from a single centralized handler to consistent, standards-based error responses.

Why Global Exception Handling Matters

Scattering try-catch blocks across every controller action might feel safe in the short term, but it doesn’t scale. Every developer ends up writing slightly different error-handling logic, which means:

  • Error responses differ in shape from one endpoint to another, making life harder for API consumers.
  • Sensitive details — connection strings, internal exception messages, stack traces — can accidentally leak to the client.
  • Logging becomes inconsistent, so production issues are harder to trace back to their root cause.
  • Controllers get cluttered with defensive code that has nothing to do with business logic.

A global exception handler solves this by intercepting unhandled exceptions in one place, logging them consistently, and returning a predictable, safe response — regardless of which endpoint threw the error.

Note: Global exception handling is not a replacement for local try-catch blocks. Use local handling only when you can actually recover from the exception — for example, retrying a transient database call. Everything else should bubble up to the global handler.

Exception Handling Approaches in ASP.NET Core

Before .NET 8, most teams handled exceptions either with per-action try-catch blocks or with custom middleware. Both work, but neither is as clean or testable as the newer IExceptionHandler interface, which was purpose-built for this exact scenario.

ApproachComplexityTestabilityMaintainabilityRecommended For
Try-catch per actionLow to set up, high to maintainPoor — logic duplicated everywherePoor as the app growsSmall scripts, one-off recovery logic
Custom middlewareModerateGoodGoodProjects on .NET 6/7 or earlier
IExceptionHandler (.NET 8+)LowExcellent — implements a single testable interfaceExcellent — centralized and modularNew ASP.NET Core 8+ projects

If you’re building on .NET 8 or later, IExceptionHandler should be your default. It plugs directly into the existing UseExceptionHandler middleware, so you get centralized handling without writing a custom middleware class from scratch.

Implementing Global Exception Handling with IExceptionHandler

The IExceptionHandler interface requires implementing a single method: TryHandleAsync. It receives the current HttpContext, the thrown exception, and a cancellation token — and returns true if the exception was handled, or false to pass it along to the next handler in the chain.

Step 1 — Create the handler class:
C#
public sealed 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 problemDetails = new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title = "An unexpected error occurred.",
            Type = "https://tools.ietf.org/html/rfc9457"
        };

        httpContext.Response.StatusCode = problemDetails.Status.Value;

        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);

        return true;
    }
}
Step 2 — Register the handler with dependency injection:
C#
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
Step 3 — Wire it into the request pipeline:
C#
app.UseExceptionHandler();

That’s it. Any unhandled exception thrown from a controller, minimal API endpoint, or middleware further down the pipeline now flows through GlobalExceptionHandler automatically — no repeated try-catch blocks required.

Returning Consistent Error Responses with ProblemDetails

Consistent error shapes matter just as much as catching the exception in the first place. ASP.NET Core’s built-in ProblemDetails type follows the Problem Details for HTTP APIs standard, which defines a predictable JSON structure for API error responses using fields like type, title, status, detail, and instance.

Note: The current standard is RFC 9457, which formally obsoletes the earlier RFC 7807. The JSON structure and the application/problem+json media type are unchanged, so existing ProblemDetails code in ASP.NET Core still works — but when documenting or citing the spec, reference RFC 9457 rather than the outdated RFC 7807.

Mapping specific exception types to specific HTTP status codes makes the API far more predictable for consumers:

C#
var (status, title) = exception switch
{
    NotFoundException => (StatusCodes.Status404NotFound, "Resource not found."),
    ValidationException => (StatusCodes.Status400BadRequest, "Validation failed."),
    UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "Unauthorized."),
    _ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred.")
};

Logging and Observability

A global handler is also the ideal place to standardize logging. Log the full exception — including the stack trace — using a structured logging framework such as Serilog or the built-in ILogger provider, while returning only a safe, generic message to the client. This keeps sensitive internals out of API responses without losing the diagnostic detail your team needs when investigating an incident.

Good practice checklist for logging in a global handler:

  • Log the complete exception object, not just exception.Message.
  • Include a correlation ID or trace ID so a client-reported error can be matched to server logs.
  • Never log sensitive data (passwords, tokens, connection strings) even in the exception object.
  • Use structured logging so errors are queryable in tools like Seq, Application Insights, or Elasticsearch.

Handling Multiple Exception Types (Handler Chaining)

You’re not limited to a single IExceptionHandler. ASP.NET Core supports registering multiple handlers, and they’re invoked in the order they were registered until one returns true:

C#
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); // catch-all, registered last

This pattern keeps each handler focused on a single concern — a ValidationExceptionHandler only deals with validation failures, while the catch-all GlobalExceptionHandler handles everything else. It’s a clean way to avoid one bloated handler class trying to do too much.

Real-World Use Case: Before vs. After

Before — scattered try-catch blocks:
C#
[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
    try
    {
        var user = _userService.GetUser(id);
        if (user is null)
            return NotFound(new { message = "User not found" });

        return Ok(user);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Error fetching user");
        return StatusCode(500, new { message = ex.Message });
    }
}

Every action repeats this pattern with slightly different response shapes, and the raw ex.Message is exposed directly to the client — a real security concern in production.

After — centralized handling with IExceptionHandler:
C#
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
    var user = await _userService.GetUserAsync(id)
        ?? throw new NotFoundException($"User {id} not found");

    return Ok(user);
}

The controller only expresses business logic. The GlobalExceptionHandler catches the NotFoundException, logs it, and returns a consistent ProblemDetails response — with zero repeated boilerplate.

Summary

Global exception handling isn’t just a nice-to-have — it’s a core part of building a production-ready ASP.NET Core API. With the IExceptionHandler interface, you get a clean, testable, and centralized way to catch unhandled exceptions, log them properly, and return consistent, standards-compliant error responses to every client. If you’re starting a new ASP.NET Core 8+ project, or refactoring an older one, moving to IExceptionHandler is one of the highest-leverage changes you can make to your error-handling strategy.

Found this article useful? Share it with your network and spark a conversation.