Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 010 / 100

Global error handling with ProblemDetails for production

One consistent error format for every failure — the RFC 9457 way.

Expona Admin8 min read

Global error handling with ProblemDetails

After years of building e-commerce platforms, CRM systems, and ride-hailing services, I've learned that error handling is often the difference between a system that's pleasant to debug and one that turns support tickets into detective work. Global error handling with ProblemDetails gives you one consistent JSON shape for every failure—whether it's a 404, validation error, or database timeout—so your frontend team never has to guess what went wrong, and your logs stay clean.

Why ProblemDetails — A standard JSON shape

Before ProblemDetails (RFC 9457), every API was a snowflake. One endpoint returned { "error": "User not found" }, another returned { "message": "...", "code": 404 }, and a third just threw a raw exception. Your frontend ended up with a mess of conditional checks, and when someone added a new endpoint, there was no contract to follow.

ProblemDetails standardizes this. When something fails, the response looks like this:

csharp
{
  "type": "https://yourapi.com/errors/not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "User with ID 123 does not exist",
  "traceId": "0HN1GDK12345:00000001"
}

Notice the five fields: type (a URI that describes the error category), title (a short label), status (the HTTP code), detail (the human-readable explanation), and traceId (tied to your logs so support can find the full stack trace).

The benefit is immediate. Your frontend code becomes predictable:

csharp
// Everywhere, same shape
if (response.status === 404) {
  showError(response.detail);
}
if (response.status === 400) {
  displayValidationErrors(response.errors); // ProblemDetails extensions
}

No more response.message vs response.error guessing games. And because the format is an RFC standard, new team members, external partners, and future you will all know what to expect.

IExceptionHandler (.NET 8+) — Centralize exception mapping

In .NET 8, Microsoft introduced IExceptionHandler, a middleware-like interface that sits between your controller and the response. Instead of scattering try-catch blocks everywhere, you implement one class that decides how each exception type becomes a response.

Here's the pattern I use on production systems:

csharp
public class AppExceptionHandler : IExceptionHandler
{
    private readonly ILogger<AppExceptionHandler> _logger;

    public AppExceptionHandler(ILogger<AppExceptionHandler> logger)
    {
        _logger = logger;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext context, Exception exception, CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "An unhandled exception occurred");

        var response = new ProblemDetails
        {
            Instance = context.Request.Path
        };

        // Map exceptions to HTTP status codes
        response.Status = exception switch
        {
            NotFoundException => StatusCodes.Status404NotFound,
            UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
            ValidationException ve => StatusCodes.Status400BadRequest,
            _ => StatusCodes.Status500InternalServerError
        };

        // Safe messages for clients, never leak internals
        response.Title = exception switch
        {
            NotFoundException => "Resource not found",
            UnauthorizedAccessException => "Unauthorized",
            ValidationException => "Validation failed",
            _ => "An error occurred processing your request"
        };

        // Detail varies by environment
        response.Detail = (exception is ValidationException ve)
            ? ve.Message
            : exception is NotFoundException or UnauthorizedAccessException
                ? exception.Message
                : "Please contact support with the traceId below";

        context.Response.StatusCode = response.Status.Value;
        await context.Response.WriteAsJsonAsync(response, cancellationToken);

        return true;
    }
}

// Custom exceptions
public class NotFoundException : Exception
{
    public NotFoundException(string message) : base(message) { }
}

public class ValidationException : Exception
{
    public ValidationException(string message) : base(message) { }
}

Notice the filtering: TryHandleAsync returns true if the handler successfully converted the exception into a response, and false if something else should try. By returning true, we're saying "I handled this, stop the pipeline here."

The logic is clean: log everything (you own your logs), return safe messages to clients (you own your API contract), and always include the traceId so support can connect a user's complaint to your actual logs.

Registration and middleware order

Registering the handler is straightforward. In your Program.cs:

csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddControllers()
    .ConfigureApiBehaviorOptions(opts =>
    {
        // Use ProblemDetails for model binding errors too
        opts.InvalidModelStateResponseFactory = context =>
        {
            var errors = context.ModelState
                .Where(x => x.Value.Errors.Count > 0)
                .ToDictionary(
                    x => x.Key,
                    x => x.Value.Errors.Select(e => e.ErrorMessage).ToArray());

            return new BadRequestObjectResult(new ProblemDetails
            {
                Title = "Validation failed",
                Status = StatusCodes.Status400BadRequest,
                Detail = "One or more validation errors occurred",
                Extensions = new Dictionary<string, object>
                {
                    { "errors", errors }
                }
            });
        };
    });

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

var app = builder.Build();

app.UseExceptionHandler();

app.MapControllers();
app.Run();

The order matters. AddExceptionHandler<T>() registers your handler, AddProblemDetails() enables the ProblemDetails middleware, and app.UseExceptionHandler() activates it in the pipeline. This must come before your route handlers so it can catch anything they throw.

Never leak internals — Logging and traceId

A common mistake is logging in your controller and also trying to build a response. You end up either logging twice or losing the stack trace. Instead, let the exception handler log everything with full context:

csharp
public async ValueTask<bool> TryHandleAsync(
    HttpContext context, Exception exception, CancellationToken cancellationToken)
{
    // Always log the full exception
    _logger.LogError(
        exception,
        "Exception: {ExceptionType} at {Path}",
        exception.GetType().Name,
        context.Request.Path);

    var traceId = context.TraceIdentifier;

    var response = new ProblemDetails
    {
        Instance = context.Request.Path,
        Extensions = new Dictionary<string, object>
        {
            { "traceId", traceId }
        }
    };

    // ... status and message mapping ...

    context.Response.StatusCode = response.Status.Value;
    await context.Response.WriteAsJsonAsync(response, cancellationToken);

    return true;
}

// Client receives:
// {
//   "title": "Database connection failed",
//   "status": 500,
//   "detail": "Please contact support with traceId below",
//   "traceId": "0HN1GDK12345:00000001"
// }

// In your logs (search by traceId):
// fail: AppExceptionHandler
//       Exception: SqlException at /api/users
//       System.Data.SqlClient.SqlException: Connection timeout
//       at System.Data.SqlClient.SqlConnection.Open()

This pattern is gold on production. A user says "I got an error on the home page," they give you the traceId, and in seconds you're looking at the exact exception and line number. No guessing, no "can you try again," no back-and-forth.

Real-world example: E-commerce order flow

Let me walk through a scenario from an e-commerce platform I worked on. When a user creates an order, several things can fail:

  1. Product doesn't exist (404)
  2. User has no payment method (validation)
  3. Payment gateway timeout (500, but recoverable)
  4. Database connection lost (500, critical)

Without ProblemDetails and centralized handlers, the frontend team would get four different response shapes. With the handler, they always get:

csharp
public class CreateOrderHandler : ICommandHandler<CreateOrderCommand, OrderDto>
{
    private readonly IProductRepository _products;
    private readonly IPaymentService _payments;

    public async Task<OrderDto> Handle(CreateOrderCommand cmd)
    {
        var product = await _products.GetAsync(cmd.ProductId);
        if (product == null)
            throw new NotFoundException($"Product {cmd.ProductId} not found");

        if (cmd.PaymentMethodId == null)
            throw new ValidationException("Payment method is required");

        var result = await _payments.ChargeAsync(cmd.PaymentMethodId, product.Price);
        if (!result.Success)
            throw new PaymentFailedException(result.Message);

        return new OrderDto { /* ... */ };
    }
}

Each exception is thrown naturally in business logic. The handler catches them all:

csharp
response.Status = exception switch
{
    NotFoundException => 404,
    ValidationException => 400,
    PaymentFailedException => 402, // Payment required
    _ => 500
};

Your frontend sees the status code and detail, and responds appropriately. Retry logic, user messaging, and logging all work in one place.

Common mistakes

  • Not logging the full exception in the handler. You register an exception handler, feel good about the clean JSON response, then wonder why support has no logs. Always log in TryHandleAsync with the full exception and its context.
  • Returning sensitive details in the response. A stack trace or database error message in a 500 response is a security leak. Log it, return a generic message with traceId to the client.
  • Forgetting to handle async exceptions. If an exception happens in an async method and you're not using the handler, it'll bypass your middleware. IExceptionHandler catches them, but only if it's registered in the pipeline before UseExceptionHandler().
  • Not including traceId in every error response. Without it, support has to ask the user for more details. With it, you're instantly at the exact log entry. Never skip this.
  • Building responses in controllers instead of the handler. You'll end up with inconsistent error shapes and scattered logging logic. Put it all in one place, let the handler own error responses.

Key takeaways

  • ProblemDetails (RFC 9457) gives every error response a consistent JSON shape: type, title, status, detail, and traceId.
  • IExceptionHandler centralizes exception mapping so you map once, not in every controller.
  • Log the full exception in the handler, return safe messages to clients, always include traceId so support can find logs in seconds.
  • Register handlers with AddExceptionHandler<T>(), then AddProblemDetails(), then app.UseExceptionHandler()—order matters.
  • Your frontend code becomes predictable; they check response.status and response.detail, no more guessing what the shape is.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles