ProblemDetails (RFC 9457)
Return every API error as application/problem+json with AddProblemDetails, IProblemDetailsService and one CustomizeProblemDetails hook.
builder.Services.AddProblemDetails(o => o.CustomizeProblemDetails = ctx =>
ctx.ProblemDetails.Instance = ctx.HttpContext.Request.Path);
app.UseExceptionHandler();
app.UseStatusCodePages();RFC 9457, which replaced RFC 7807, defines one JSON shape for HTTP API errors, served as application/problem+json. It has five standard members: type (a URI naming the kind of problem, the member clients branch on), title (a short summary that stays the same for that type), status (the HTTP status, repeated for convenience), detail (a human explanation of this occurrence) and instance (an identifier for this occurrence). Anything else is an extension member at the top level, such as traceId or available. ASP.NET Core models it as ProblemDetails (namespace Microsoft.AspNetCore.Mvc), whose Extensions dictionary is flattened into the JSON object.
What AddProblemDetails switches on
builder.Services.AddProblemDetails() registers IProblemDetailsService and a default JSON writer. On its own it changes no response: it is the shared service other components call when they need to write an error.
UseExceptionHandler()writes a 500 problem for unhandled exceptions.UseStatusCodePages()fills in bare 4xx and 5xx responses such as a routing 404.- The developer exception page writes a problem, stack trace included, for non-HTML clients.
TypedResults.ProblemandTypedResults.ValidationProblemgo through the service when it is registered, and serialise directly, withouttraceIdor customisation, when it is not.- MVC's
ProblemDetailsFactory, used byProblem()and the[ApiController]client error mapping, applies the same customisation.
Defaults the writer fills in
| Member | Default when you leave it null |
|---|---|
status | The response status code |
type | A link to the RFC 9110 section for common codes, e.g. https://tools.ietf.org/html/rfc9110#section-15.5.5 for 404; nothing for unmapped codes such as 402 |
title | The reason phrase (Not Found); for 500, An error occurred while processing your request. |
traceId | Activity.Current?.Id when the request has an activity (the default with logging on), otherwise HttpContext.TraceIdentifier |
The default writer only writes when the Accept header allows JSON: absent, */*, application/json or application/problem+json. For Accept: text/html, TryWriteAsync returns false and the caller falls back; status code pages then writes plain text.
CustomizeProblemDetails
options.CustomizeProblemDetails runs for every problem written through the service, after the defaults. Use it for what every error must carry: instance, the service name, a tenant id. ProblemDetailsContext gives you the HttpContext, the ProblemDetails and, from the exception handler, the Exception. Keep it cheap and make sure it cannot throw: it runs on the error path, and an exception there turns a clean 404 into a 500.
Decisions a team makes
- A stable
typeURI per problem, such ashttps://errors.example.com/out-of-stock, documented in the API reference. Clients branch ontypeor anerrorCodeextension, never ontitleordetailtext, which you must be free to reword. - One shape for every error, including the 401, 404, 405 and 429 the framework produces. Mixed shapes force every client into defensive parsing.
detailis for people. Never copyexception.Messageinto it in production.
Production failure modes
Endpoints returning their own {"error": "..."} objects next to framework problems; clients that accept only application/json and reject application/problem+json; and extension values of types the JSON options cannot serialise, which under Native AOT must be known to your source-generated JSON context.
Example
When to use it
- Return 409 with type https://errors.example.com/out-of-stock and the available quantity when an order line asks for more KB-104 keyboards than are in stock.
- Give support a traceId in every error body so a customer's screenshot leads straight to the failing request in the logs.
- Reject /orders requests without an X-Store-Id header from middleware using the same problem shape the endpoints use.
More examples
A typed client that understands problem responses
The client branches on <code>type</code>, not on <code>title</code> or <code>detail</code>, so the server can reword messages without breaking callers. <code>ProblemDetails</code> deserialises unknown members into <code>Extensions</code> as <code>JsonElement</code> values, so read them by kind instead of casting. Keeping the <code>traceId</code> in the thrown exception means the caller's own logs point straight at the failing request on the inventory service.
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
public sealed record ReserveStock(string Sku, int Quantity);
public sealed class OutOfStockException(string sku, int available)
: Exception($"Only {available} of {sku} left.")
{
public string Sku { get; } = sku;
public int Available { get; } = available;
}
public sealed class InventoryClient(HttpClient http)
{
private const string OutOfStockType = "https://errors.example.com/out-of-stock";
public async Task ReserveAsync(ReserveStock request, CancellationToken cancellationToken)
{
using var response = await http.PostAsJsonAsync("/reservations", request, cancellationToken);
if (response.IsSuccessStatusCode)
{
return;
}
if (response.Content.Headers.ContentType?.MediaType != "application/problem+json")
{
response.EnsureSuccessStatusCode();
}
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(cancellationToken);
if (problem?.Type == OutOfStockType
&& problem.Extensions.TryGetValue("available", out var value)
&& value is JsonElement { ValueKind: JsonValueKind.Number } available)
{
throw new OutOfStockException(request.Sku, available.GetInt32());
}
object? traceId = null;
problem?.Extensions.TryGetValue("traceId", out traceId);
throw new HttpRequestException(
$"Inventory returned {(int)response.StatusCode} {problem?.Title} (trace {traceId})",
null,
response.StatusCode);
}
}A contract test: every error is problem+json
This test pins the contract rather than one endpoint: a routing 404, a 405 and an application 404 must all come back as <code>application/problem+json</code> with a matching <code>status</code> and a <code>traceId</code>. It fails the day someone removes <code>UseStatusCodePages()</code> or adds an endpoint that writes its own error object, which is how inconsistent error shapes usually creep in.
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
public partial class Program;
public sealed class ProblemContractTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Theory]
[InlineData("GET", "/orders/999999", HttpStatusCode.NotFound)]
[InlineData("PUT", "/orders/1042", HttpStatusCode.MethodNotAllowed)]
[InlineData("GET", "/no-such-route", HttpStatusCode.NotFound)]
public async Task Errors_use_problem_json(string method, string path, HttpStatusCode expected)
{
var ct = TestContext.Current.CancellationToken;
var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Store-Id", "store-12");
using var response = await client.SendAsync(new HttpRequestMessage(new HttpMethod(method), path), ct);
Assert.Equal(expected, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(ct);
Assert.Equal((int)expected, problem!.Status);
Assert.True(problem.Extensions.ContainsKey("traceId"));
}
}The wire format clients see
Extension members sit beside the standard ones at the top level, not under a nested object, because <code>Extensions</code> is flattened on write. RFC 9457 makes <code>instance</code> a URI reference, so it holds the path and the method goes in its own extension member. The <code>type</code> URI is an identifier first; making it resolve to a documentation page that explains the problem and the fix is good practice but optional. <code>status</code> must equal the HTTP status line, which the framework does not enforce for you.
{
"type": "https://errors.example.com/out-of-stock",
"title": "Not enough stock",
"status": 409,
"detail": "Requested 5 of KB-104, only 2 available.",
"instance": "/orders/1042/lines",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"method": "POST",
"service": "orders-api",
"sku": "KB-104",
"requested": 5,
"available": 2
}
Discussion