.NET & C# Development · Lesson 12 of 92

Never Return a Raw 500 Again — ProblemDetails & Global Errors

Why Raw Errors Are Unacceptable

A raw 500 response with an empty body (or worse, a stack trace) tells the client nothing useful and exposes internals. RFC 9457 defines a standard JSON format that every API client can parse consistently.

JSON
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Not Found",
  "status": 404,
  "detail": "Order 42 was not found.",
  "traceId": "00-abc123-def456-00"
}

Every error from every endpoint should look like this.


Step 1 — Add ProblemDetails Middleware

C#
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddProblemDetails(); // registers IProblemDetailsService

var app = builder.Build();

app.UseExceptionHandler(); // catches unhandled exceptions
app.UseStatusCodePages();  // turns bare 404/405 into ProblemDetails

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

With UseExceptionHandler() (no path argument) + AddProblemDetails(), ASP.NET Core automatically formats unhandled exceptions as ProblemDetails. That gets you 80% of the way there. The remaining 20% is mapping your own exceptions.


Step 2 — Define Custom Exceptions

C#
public abstract class AppException : Exception
{
    protected AppException(string message) : base(message) { }
}

public class NotFoundException : AppException
{
    public NotFoundException(string resource, object id)
        : base($"{resource} with id '{id}' was not found.") { }
}

public class ValidationException : AppException
{
    public IReadOnlyDictionary<string, string[]> Errors { get; }

    public ValidationException(IDictionary<string, string[]> errors)
        : base("One or more validation errors occurred.")
    {
        Errors = new Dictionary<string, string[]>(errors);
    }
}

public class ConflictException : AppException
{
    public ConflictException(string message) : base(message) { }
}

Throw these from your services. The handler (below) maps them to status codes.


Step 3 — IExceptionHandler (.NET 8+)

IExceptionHandler is the clean way to handle exceptions globally. You can register multiple handlers; they run in registration order until one returns true.

C#
public class AppExceptionHandler : IExceptionHandler
{
    private readonly IProblemDetailsService _problemDetailsService;
    private readonly ILogger<AppExceptionHandler> _logger;

    public AppExceptionHandler(
        IProblemDetailsService problemDetailsService,
        ILogger<AppExceptionHandler> logger)
    {
        _problemDetailsService = problemDetailsService;
        _logger = logger;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        var (statusCode, title) = exception switch
        {
            NotFoundException    => (404, "Not Found"),
            ValidationException  => (400, "Validation Failed"),
            ConflictException    => (409, "Conflict"),
            UnauthorizedAccessException => (401, "Unauthorized"),
            _                    => (0, null) // let default handler take it
        };

        if (statusCode == 0) return false; // not handled

        _logger.LogWarning(exception, "Handled exception: {Title}", title);

        httpContext.Response.StatusCode = statusCode;

        var context = new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception = exception,
            ProblemDetails =
            {
                Status = statusCode,
                Title = title,
                Detail = exception.Message
            }
        };

        // Add validation errors if present
        if (exception is ValidationException validationEx)
        {
            context.ProblemDetails.Extensions["errors"] = validationEx.Errors;
        }

        await _problemDetailsService.WriteAsync(context);
        return true;
    }
}

Register it:

C#
builder.Services.AddExceptionHandler<AppExceptionHandler>();
builder.Services.AddProblemDetails();

Step 4 — Add traceId to Every Response

The traceId field makes it trivial to correlate a client error with your logs.

C#
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] =
            Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;

        ctx.ProblemDetails.Extensions["instance"] =
            $"{ctx.HttpContext.Request.Method} {ctx.HttpContext.Request.Path}";
    };
});

Now every ProblemDetails response — whether from your handler, a 404, or a 405 — contains traceId automatically.


Step 5 — Extended Validation Error Format

JSON
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "Validation Failed",
  "status": 400,
  "detail": "One or more validation errors occurred.",
  "traceId": "00-abc123-def456-00",
  "errors": {
    "Name": ["Name is required", "Name must be less than 100 characters"],
    "Price": ["Price must be greater than 0"]
  }
}

The errors dictionary maps field names to arrays of messages — the same format ASP.NET Core uses for ModelState errors. If you use FluentValidation, convert ValidationResult to Dictionary<string, string[]> before throwing:

C#
public static class ValidationResultExtensions
{
    public static ValidationException ToException(this ValidationResult result)
    {
        var errors = result.Errors
            .GroupBy(e => e.PropertyName)
            .ToDictionary(
                g => g.Key,
                g => g.Select(e => e.ErrorMessage).ToArray());

        return new ValidationException(errors);
    }
}

Putting It Together — Full Program.cs

C#
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddExceptionHandler<AppExceptionHandler>();
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] =
            Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
    };
});

var app = builder.Build();

app.UseExceptionHandler();
app.UseStatusCodePages();

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

Testing It

C#
// In a service — just throw:
public async Task<Order> GetOrderAsync(int id, CancellationToken ct)
{
    var order = await _db.Orders.FindAsync([id], ct);
    if (order is null)
        throw new NotFoundException(nameof(Order), id);

    return order;
}

The controller stays clean:

C#
[HttpGet("{id}")]
public async Task<Order> GetOrder(int id, CancellationToken ct)
{
    return await _orderService.GetOrderAsync(id, ct);
    // NotFoundException bubbles up → handler maps it → 404 ProblemDetails
}

Summary

| Component | Role | |---|---| | AddProblemDetails() | Registers the formatter | | UseExceptionHandler() | Catches unhandled exceptions | | UseStatusCodePages() | Formats bare status codes | | IExceptionHandler | Maps your exceptions to status codes | | CustomizeProblemDetails | Adds traceId and custom fields globally |

One handler, one format, every endpoint. Clients get consistent, parseable errors and you get correlation IDs in your logs.