Consistent API Error Handling in ASP.NET Core with ProblemDetails

Open the network tab on a typical internal API and trigger a few failures. You'll often find three different error shapes from the same service: a raw string from one endpoint, an ad-hoc { "error": "..." } object from another, and an HTML error page where someone forgot the API even had that path. Every consumer of that API now writes three error parsers — and the mobile team quietly hates you.

There's a standard for this: Problem Details (RFC 7807, updated by RFC 9457), and ASP.NET Core supports it natively. Here's how I set up error handling on every API I build.

The shape

A Problem Details response is a JSON object with a media type of application/problem+json:

json
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Not Found",
  "status": 404,
  "detail": "Order 42 does not exist.",
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00"
}

One shape for every failure: clients write one error handler, support staff get a traceId they can hand to you, and you get to stop bikeshedding error formats forever.

Step 1: Turn it on everywhere

Three registrations give you consistent ProblemDetails for all non-success responses:

csharp
// Program.cs
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] =
            ctx.HttpContext.TraceIdentifier;
    };
});

var app = builder.Build();

app.UseExceptionHandler();   // unhandled exceptions → 500 ProblemDetails
app.UseStatusCodePages();    // bare 404/405/415 etc. → ProblemDetails

AddProblemDetails registers the writer; the two middleware calls make sure both exceptions and empty status code responses come out in the standard shape. With [ApiController], model validation failures already return the validation flavor (ValidationProblemDetails) with a per-field errors dictionary — so your 400s are covered too.

Step 2: Map domain exceptions with IExceptionHandler

.NET 8 introduced IExceptionHandler, which finally gives global exception mapping a clean home (no more custom middleware boilerplate):

csharp
public class DomainExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken ct)
    {
        (int status, string title) = exception switch
        {
            EntityNotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
            DuplicateEntityException => (StatusCodes.Status409Conflict, "Conflict"),
            DomainValidationException => (StatusCodes.Status422UnprocessableEntity, "Validation failed"),
            _ => (0, string.Empty)
        };

        if (status == 0) return false; // not ours — let the default 500 handle it

        await Results.Problem(
                statusCode: status,
                title: title,
                detail: exception.Message)
            .ExecuteAsync(httpContext);

        return true;
    }
}

// Program.cs — order matters: specific handlers first
builder.Services.AddExceptionHandler<DomainExceptionHandler>();

Handlers run in registration order; returning false passes the exception to the next handler or the default. This keeps the mapping between your domain's failure modes and HTTP semantics in exactly one file.

Step 3: Decide what exceptions are for

Middleware only solves the plumbing. The design question remains: when should code throw? The convention that has served my teams well:

  • Expected, per-request outcomes — "order not found", "insufficient stock" — are results, not exceptions. Return them (Results<Ok<T>, NotFound> in minimal APIs, or a result type from your services) and map to 4xx.
  • Broken invariants and infrastructure failures — database down, config missing, bug — throw. They surface as 500s via the handler, get logged with full detail, and page someone if frequent.

Exceptions as control flow for routine outcomes is both a performance tax and a readability lie; 500s for routine "not found" cases wreck your alerting signal. Draw the line deliberately.

Step 4: Don't leak, don't hide

Two failure modes to avoid, one per direction:

Leaking internals. exception.Message for unexpected exceptions can contain connection strings, file paths, SQL fragments. The default UseExceptionHandler behavior is safe (generic 500 body); keep it that way in production and put the detail in your logs, correlated by traceId. Only domain exceptions you wrote — with messages you authored for users — belong in detail.

Hiding everything. The opposite sin: a blanket try/catch that returns 200 with { "success": false }. HTTP status codes are your API's contract with caches, retry policies, monitors, and every generic HTTP client on earth. Use them.

csharp
// Development gets full detail, production stays terse
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler();
}

The payoff

This whole setup is maybe forty lines of code, and it's some of the highest-leverage code in the service: every client gets one error contract, every incident comes with a trace ID, validation errors are machine-readable, and adding a new domain failure mode is a one-line addition to a switch expression. Error handling is the part of the API your consumers meet on their worst day — make it the most predictable part.

Where to go next