Back to all posts
·5 min read

Consistent error handling in ASP.NET Core: IExceptionHandler and Problem Details

An API's success responses are usually designed with care. Its error responses often just happen: one endpoint returns plain text, another returns { "error": "..." }, and a third hands the development stack trace straight to the client. The client developer then has to write separate error-parsing code for every endpoint.

In this post I'll show how to catch errors in a single place in ASP.NET Core, return them in the standard Problem Details format, and do it without leaking internals. The goal is to treat error behavior as a contract instead of scattering it across endpoints.

Why a consistent error format matters

Client-side error handling comes down to three questions: what happened, should I retry, and what should I show the user? If the response shape differs per endpoint, you cannot answer these reliably.

A shared format also helps on the server. If every error response carries the same trace identifier, you can find the error a user reported in the logs within seconds. Consistency lowers the cost for both clients and operations.

Problem Details: the standard envelope

The standard that defines the error body for HTTP APIs is RFC 9457 (previously RFC 7807). Its core fields are:

  • type: a URI that identifies the kind of error.
  • title: a short, human-readable summary of that kind.
  • status: the HTTP status code.
  • detail: an explanation specific to this occurrence.
  • instance: a value that identifies the occurrence itself.

You can add any extra fields you need (extensions). ASP.NET Core supports this format through the ProblemDetails class and the application/problem+json content type.

Configuring the service

The first step is to turn the error responses the framework generates into Problem Details as well:

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();

UseExceptionHandler catches unhandled exceptions, and UseStatusCodePages turns bodiless responses such as a plain 404 or 405 into Problem Details. Adding traceId to every response inside CustomizeProblemDetails is a one-line change with a large payoff.

Mapping exceptions with IExceptionHandler

IExceptionHandler, introduced in .NET 8, moves exception mapping into a testable class without writing custom middleware:

public sealed class ApiExceptionHandler(
    IProblemDetailsService problemDetails,
    ILogger<ApiExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context, Exception exception, CancellationToken ct)
    {
        var (status, title) = exception switch
        {
            ValidationException => (StatusCodes.Status400BadRequest, "Invalid request"),
            NotFoundException   => (StatusCodes.Status404NotFound, "Resource not found"),
            ConflictException   => (StatusCodes.Status409Conflict, "Conflict"),
            _                   => (StatusCodes.Status500InternalServerError, "Unexpected error")
        };

        if (status >= 500)
            logger.LogError(exception, "Unhandled exception");
        else
            logger.LogInformation("Client error: {Type}", exception.GetType().Name);

        context.Response.StatusCode = status;
        return await problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = context,
            Exception = exception,
            ProblemDetails = { Status = status, Title = title }
        });
    }
}

You register it with builder.Services.AddExceptionHandler<ApiExceptionHandler>();. If you add several handlers, they are tried in registration order, and the first one that returns true finishes the job. If none handles the exception, the default behavior kicks in.

Handling field errors separately

For validation errors, the client needs to know which field was rejected and why. This is what ValidationProblemDetails is for:

return Results.ValidationProblem(new Dictionary<string, string[]>
{
    ["email"] = ["Enter a valid email address."],
    ["quantity"] = ["Quantity must be at least 1."]
});

The errors field in the output is grouped by field name. A client can bind this structure directly to form fields. In controller-based projects, the [ApiController] attribute already returns model validation errors in this shape.

Don't mix field errors with business rule errors. "The email is invalid" is a field error. "This order can no longer be cancelled" is a business rule error; it is better returned as 409 or 422 with a machine-readable type value.

Preventing leaks

An error response should not give an attacker information. A few rules:

  • In production, don't put the exception message into detail. It can carry a connection string or a piece of SQL.
  • Keep stack traces in the logs only; if needed, include them in the response in development (the Exception context exists for this).
  • For a 500 response, give a generic title and look up the details in the logs by traceId.
  • Think about the difference between 404 and 403 so that you don't reveal whether a resource exists.

If you consider the messages of your own exception types safe to show to users, do so deliberately: put only the message of types you defined yourself, such as ValidationException, into detail, and keep everything else generic.

Testing error responses

If the error contract isn't tested, it erodes over time. Send a request with WebApplicationFactory and verify the status code, content type and fields:

var response = await client.GetAsync("/orders/missing");

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

var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.Equal(404, problem!.Status);
Assert.True(problem.Extensions.ContainsKey("traceId"));

Also add a test where an endpoint deliberately throws, and verify that the exception message does not appear in the response. Leak regressions are mostly caught by a test like this.

Closing

Consistent error handling has three parts: AddProblemDetails, which covers framework errors too; IExceptionHandler, which gathers exception mapping in one place; and a traceId added to every response. On top of these come separating field errors with ValidationProblemDetails and writing details to the log instead of the response in production.

Start small: first bring all existing errors into one format with AddProblemDetails and UseExceptionHandler, then write handlers for the three most common exception types. Your client team will notice on day one that the error format is now predictable.