An API returns two kinds of results: success or error. Both need a consistent structure. During my career I’ve met a dozen different structures for both paths. Some were good, some were not. The errors were the worst of it.

Let me show you how to avoid it.

Non-standardized errors

Some of my own APIs, and plenty of third-party ones, returned errors like this:

{
  "message": "Something went wrong",
  "status": 500
}

You can guess what went wrong. The same body comes back whether the endpoint could not find the product, rejected the payload, or lost its database connection, so guessing is all you have.

Web and mobile parse the same endpoint differently, every new endpoint adds another shape for them to handle, and the documentation grows a section to explain each one.

The client has to know what type of failure occurred, what status it corresponds to, what exactly happened in the present scenario, and if there is an identifier that it can use for branching without parsing English. ProblemDetails carries that in a single body.

What is ProblemDetails?

ProblemDetails is a standard JSON (or XML) format for API errors. Responses carry a dedicated content type:

Content-Type: application/problem+json

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Not Found",
  "status": 404,
  "detail": "No product with id 2 exists.",
  "instance": "/products/2"
}

Every property has one job, and all of them are optional:

  • type: a URI identifying the error category
  • title: a short, general, human-readable summary of the error
  • status: the HTTP status code
  • detail: what went wrong in this exact occurrence
  • instance: the request path or resource that triggered the problem.

RFC 7807 defined the format first. RFC 9457 obsoleted it and now holds the specification. RFC 9457 adds a registry of common problem type URIs, plus guidance on type URIs you cannot dereference and on reporting more than one problem in a single response.

ProblemDetails in .NET

Registration takes three lines in Program.cs:

var builder = WebApplication.CreateBuilder(args);

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

var app = builder.Build();

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

What each line does:

Call Results.Problem inside an endpoint and you build the object yourself:

app.MapGet("/products/{id:int}", (int id) =>
{
    if (id == 1)
    {
        return Results.Ok(new Product(1, "Keyboard", 129.99m));
    }

    return Results.Problem(
        type: "https://example.com/problems/product-not-found",
        title: "Product not found",
        statusCode: StatusCodes.Status404NotFound,
        detail: $"No product with id {id} exists.",
        instance: $"/products/{id}",
        extensions: new Dictionary<string, object?>
        {
            ["code"] = "product_not_found"
        });
});

GET /products/2 returns:

{
  "type": "https://example.com/problems/product-not-found",
  "title": "Product not found",
  "status": 404,
  "detail": "No product with id 2 exists.",
  "instance": "/products/2",
  "code": "product_not_found",
  "traceId": "00-685f2a4fab7ee669809335c6e1fa2071-12380648f02718eb-00"
}

code and traceId are extension members.

code is set per problem so a client can switch on product_not_found rather than string-match title.

traceId is set once in CustomizeProblemDetails, so it lands on every error the API.

Any property can be customized. Custom properties always go under extensions.

Handle validation errors with ProblemDetails

ValidationProblemDetails inherits from ProblemDetails and adds an errors property of type IDictionary<string, string[]>. Each key names a field that failed validation, and the value holds every message for that field.

app.MapPost("/products", (CreateProduct request) =>
{
    var errors = new Dictionary<string, string[]>();

    if (string.IsNullOrWhiteSpace(request.Name))
        errors["name"] = ["Name is required."];

    if (request.Price <= 0)
        errors["price"] = ["Price must be greater than zero."];

    return errors.Count > 0
        ? Results.ValidationProblem(errors)
        : Results.Created("/products/2", request);
});

The response keeps the same envelope and adds the field map:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "name": [
      "Name is required."
    ],
    "price": [
      "Price must be greater than zero."
    ]
  },
  "traceId": "00-2c30fbbb4a1cc5b960df3921d28baeb1-a3c1e46aeec8b6c6-00"
}

Catch FluentValidation’s ValidationException, or whatever your library throws, and map it into the same shape.

Global error handling

Every endpoint throws at some point. To avoid handling that in each one, put a global handler behind the IExceptionHandler interface.

It exposes a single method, TryHandleAsync. Return true once you have written a response, or false to pass the exception to the next handler in the chain.

IExceptionHandler sits at the application level and sees anything that reaches it unhandled. When recovery needs controller or action context, an exception filter runs inside the MVC pipeline instead.

UseExceptionHandler is not an alternative to IExceptionHandler. The first is the middleware. The second tells that middleware how specific exceptions map to specific responses.

Register the handler next to ProblemDetails:

builder.Services.AddExceptionHandler<ApiExceptionHandler>();
sealed class ApiExceptionHandler(ILogger<ApiExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context,
        Exception exception,
        CancellationToken cancellationToken)
    {
        var (status, title, detail) = exception switch
        {
            ProductNotFoundException => (
                StatusCodes.Status404NotFound,
                "Product not found",
                exception.Message),

            _ => (
                StatusCodes.Status500InternalServerError,
                "Unexpected error",
                "Something went wrong. Contact support with the trace ID.")
        };

        if (status == StatusCodes.Status500InternalServerError)
            logger.LogError(exception, "Unhandled exception");

        await Results.Problem(
            title: title,
            detail: detail,
            statusCode: status
        ).ExecuteAsync(context);

        return true;
    }
}

The switch splits known failures from unknown ones:

  • ProductNotFoundException becomes a public 404 response.
  • Anything else becomes a safe 500 response.

You never return the unknown exception’s message, because it can carry connection strings, file paths, or table names. The client gets a traceId instead and quotes it back to you, and you match it against the log entry the handler already wrote.

Which one should you use?

ScenarioApproach
Known error directly in an endpointResults.Problem()
Request validation failureValidationProblemDetails
Unhandled, exception-based failureIExceptionHandler
Empty 4xx/5xx responseUseStatusCodePages()
Properties required on every errorCustomizeProblemDetails

Summary

ProblemDetails ships with the framework. AddProblemDetails() plus the two middleware calls is the whole setup, and from there every failure leaves the API in one shape instead of whatever each endpoint happened to return before.