Articles › ASP.NET Core

Global Error Handling in ASP.NET Core with IExceptionHandler and ProblemDetails

Handle errors consistently in ASP.NET Core APIs: UseExceptionHandler, IExceptionHandler, ProblemDetails, custom exceptions, mapping to status codes and logging without leaking secrets.

Errors will happen: a database times out, a client sends a request for something that doesn't exist, a bug slips through. What matters is how your API responds. Without a strategy, you get a mix of stack traces, HTML error pages and random JSON shapes. With one, every error is logged once, mapped to the right status code and returned in a single, predictable format.

Goals of a good error strategy

  1. Consistent format — clients parse one error shape everywhere.
  2. Correct status codes — 404 for missing resources, 409 for conflicts, 500 only for real server failures.
  3. No sensitive leaks — no stack traces, SQL or internal paths in production.
  4. Traceability — a traceId links the response to your logs.
  5. No try/catch noise — controllers stay clean.

ProblemDetails: the standard error format

RFC 9457 defines Problem Details for HTTP APIs, a JSON format ASP.NET Core supports natively:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Not Found",
  "status": 404,
  "detail": "Article 'react-hooks-guide' was not found.",
  "instance": "/api/articles/react-hooks-guide",
  "traceId": "00-6f1b2c...-01"
}

Enable it:

builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier;
        ctx.ProblemDetails.Instance = ctx.HttpContext.Request.Path;
    };
});

With this, UseExceptionHandler, UseStatusCodePages and [ApiController] validation errors all produce ProblemDetails.

Custom exceptions for expected failures

Model expected business failures as specific exceptions (or results — more on that later):

public abstract class AppException(string message) : Exception(message);

public sealed class NotFoundException(string resource, object key)
    : AppException($"{resource} '{key}' was not found.");

public sealed class ConflictException(string message) : AppException(message);

public sealed class BusinessRuleException(string message) : AppException(message);

Services throw them where the problem is detected:

public async Task<ArticleDto> GetAsync(string slug, CancellationToken ct) =>
    await db.Articles.Where(a => a.Slug == slug)
        .Select(a => new ArticleDto(a.Title, a.Content))
        .FirstOrDefaultAsync(ct)
    ?? throw new NotFoundException("Article", slug);

IExceptionHandler (.NET 8+)

IExceptionHandler is the modern, testable way to centralize exception handling:

public sealed class GlobalExceptionHandler(
    IProblemDetailsService problemDetails,
    ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context, Exception exception, CancellationToken ct)
    {
        var (status, title) = exception switch
        {
            NotFoundException       => (StatusCodes.Status404NotFound, "Resource not found"),
            ConflictException       => (StatusCodes.Status409Conflict, "Conflict"),
            BusinessRuleException   => (StatusCodes.Status422UnprocessableEntity, "Business rule violated"),
            UnauthorizedAccessException => (StatusCodes.Status403Forbidden, "Forbidden"),
            OperationCanceledException  => (499, "Client closed request"),
            _                       => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
        };

        if (status >= 500)
            logger.LogError(exception, "Unhandled exception for {Path}", context.Request.Path);
        else
            logger.LogInformation("Handled {ExceptionType}: {Message}", exception.GetType().Name, exception.Message);

        context.Response.StatusCode = status;

        return await problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = context,
            Exception = exception,
            ProblemDetails =
            {
                Status = status,
                Title = title,
                // expose messages only for expected (4xx) errors
                Detail = exception is AppException ? exception.Message : null
            }
        });
    }
}

Register it and add the middleware first in the pipeline:

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

var app = builder.Build();

app.UseExceptionHandler();   // must be early
app.UseStatusCodePages();    // ProblemDetails for empty 404/405 responses too

You can register several handlers; they run in order until one returns true — handy for separating, say, validation exceptions from database exceptions.

Warning: Never return exception.Message for unexpected exceptions. Messages from SqlException or NullReferenceException can reveal table names, file paths and internal logic.

Mapping database errors

Unique constraint violations are a classic: two users register the same email at the same moment, both pass validation, and the database rejects one. Translate that into a 409 Conflict:

catch (DbUpdateException ex) when (ex.InnerException is SqlException { Number: 2601 or 2627 })
{
    throw new ConflictException("This email is already registered.");
}

Exceptions or results?

Exceptions are expensive and easy to overuse for control flow. Many teams use a Result pattern for expected failures and reserve exceptions for truly exceptional situations:

public async Task<Result<OrderDto>> PlaceOrderAsync(PlaceOrder cmd, CancellationToken ct)
{
    if (!await stock.IsAvailableAsync(cmd.Items, ct))
        return Result<OrderDto>.Failure("Some items are out of stock.");
    // ...
    return Result<OrderDto>.Success(dto);
}

// Controller
var result = await orders.PlaceOrderAsync(cmd, ct);
return result.IsSuccess
    ? Ok(result.Value)
    : Problem(statusCode: 422, title: "Order rejected", detail: result.Error);

A pragmatic split:

Situation Approach
Invalid input format validation → 400
Expected business failure Result (or a custom exception) → 4xx
Resource not found NotFound() or NotFoundException
Bug, outage, timeout let it throw → global handler → 500

Don't catch what you can't handle

// ❌ Swallowing errors hides bugs and returns misleading 200s
try { await service.SaveAsync(dto); }
catch { }

// ❌ Logging and rethrowing at every layer duplicates log entries
catch (Exception ex) { logger.LogError(ex, "..."); throw; }

Catch exceptions only where you can do something meaningful — retry, translate, compensate. Let everything else bubble up to the global handler, which logs it exactly once.

Logging with context

With structured logging (Serilog or the built-in logger), include identifiers rather than dumping objects:

logger.LogError(ex, "Payment failed for order {OrderId}, user {UserId}", order.Id, userId);

Combine the traceId returned to the client with the same id in your logs, and support requests become a one-minute search instead of guesswork.

Development vs production

if (app.Environment.IsDevelopment())
    app.UseDeveloperExceptionPage();   // full details locally
else
    app.UseExceptionHandler();         // safe ProblemDetails in production

Testing error handling

[Fact]
public async Task Missing_article_returns_problem_details_404()
{
    var response = await client.GetAsync("/api/articles/does-not-exist");

    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    Assert.Equal("application/problem+json", response.Content.Headers.ContentType!.MediaType);
}

Summary

Centralize error handling with IExceptionHandler and UseExceptionHandler, return every error as ProblemDetails with a traceId, map expected failures to precise 4xx codes, translate database constraint errors, and never leak internal details in production. Catch exceptions only where you can handle them, and your API will be both robust and easy to debug.

FAQ

What's the difference between UseExceptionHandler and IExceptionHandler?

UseExceptionHandler is the middleware that catches exceptions; IExceptionHandler implementations are the pluggable logic it calls (.NET 8+).

Should validation errors go through the exception handler?

No. [ApiController] or your validation filter already returns ValidationProblemDetails with field-level errors.

Is it OK to return 500 for everything?

No. Clients can't distinguish "you made a mistake" from "we broke". Correct 4xx codes let them react properly.