
Error responses are part of an API contract. Still, many systems grow them incidentally: one endpoint returns { "error": "not found" }, another returns an array of validation errors, a gateway adds its own structure and an unexpected exception ends up as another JSON shape. That is inconvenient for people and brittle for client applications.
Problem Details solves this exact problem. The standard defines a consistent JSON format for HTTP error responses. The current specification is RFC 9457, the successor to RFC 7807. The media type is application/problem+json.
The core model is deliberately small:
type: a stable URI that identifies the kind of problemtitle: a short human-readable summary of the problem typestatus: the HTTP status codedetail: a concrete description of this specific occurrenceinstance: a URI or path for this specific occurrence- extension members: additional machine-readable data such as
code,traceId, or validation details
That turns an error response from text into a readable and machine-processable contract.
Why Problem Details exists
HTTP already has status codes, but status codes are intentionally broad. 400 Bad Request, 404 Not Found and 409 Conflict describe the technical class of a problem, not the domain reason behind it. For an API contract, that is not enough.
A 409 Conflict can mean that an order has already been paid, a user name is already taken, an optimistic concurrency token is stale, or a customer is not allowed to place an order at the moment. The status code stays the same, but the client behavior is different.
Without a standard, proprietary error formats appear quickly:
1{
2 "message": "Order not found"
3}
or:
1{
2 "success": false,
3 "errorCode": "ORDER_NOT_FOUND",
4 "errorMessage": "Order not found"
5}
Such formats work locally, but they scale poorly. Every API, gateway and client application needs special handling. OpenAPI documentation becomes inconsistent. Error texts accidentally become API contracts. Once multiple teams, services, or frontends are involved, this becomes expensive.
Problem Details separates the responsibilities more cleanly. HTTP describes the transport-level class, type describes the kind of problem, extension members provide stable technical details and detail remains a human-readable description of the specific occurrence.
Why Problem Details is the future for HTTP APIs
Problem Details is not future-ready because the format is complex. It is future-ready because it is small, standardized and compatible with existing HTTP infrastructure.
The most important point is interoperability. Frameworks, API gateways, reverse proxies, observability systems, OpenAPI tools and client SDKs can recognize a common error format. An API does not need to invent a private error protocol just to transport additional information.
That fits modern systems particularly well:
- Microservices need consistent errors across service boundaries.
- Frontends need stable error codes instead of localized text.
- Mobile applications need backward-compatible error contracts because old app versions remain in use for a long time.
- Observability needs correlation data such as
traceIdwithout exposing internal implementation details. - API documentation needs a reusable schema for error responses.
Problem Details is therefore not a replacement for good API design. It is the shared container that good API design can use for errors.
How APIs benefit
For APIs, the largest benefit is consistency. Errors are no longer modeled endpoint by endpoint as accidental DTOs. They follow one shared contract.
That reduces duplication. Exception handlers, validation logic, status code pages and explicit domain errors can use the same infrastructure. A central place can add traceId, instance, internal classification and safe default text.
Security also becomes easier. Internal exceptions, stack traces, SQL errors and downstream system messages do not belong in public API responses. Problem Details lets the API return a precise but controlled response. The internal cause goes to logs and traces, while the external response remains stable and safe.
Documentation benefits as well. OpenAPI can describe successful responses and Problem Details responses for each endpoint. That makes errors an explicit part of the API contract instead of an implicit side effect.
How client applications benefit
Client applications only benefit fully when Problem Details is implemented correctly. The key rule is that clients should not branch on title or detail. Those fields are human-readable, may be localized and can change in wording.
Stable client behavior belongs on stable machine-readable values:
typeas the standard identifier for the problem type- an additional
codefield as a compact domain key errorsor similar extension members for validation detailstraceIdfor support and correlation
A web application can map validation errors directly to form fields. A mobile application can offer retry behavior for a temporary conflict. A back-office application can show a domain-specific message for customer.blocked and navigate back to search for order.not_found. None of these decisions need to parse an English error sentence.
That is the real productivity gain: errors become actionable, not merely visible.
What correct implementation means
A good Problem Details implementation is more than calling Results.Problem() in a few places.
Stable rules matter:
- Each problem type gets a durable
typeURI. statusmatches the actual HTTP status code.titlestays short and stable for a given problem type.detaildescribes only the concrete occurrence and contains no confidential data.instanceidentifies the request or resource, not the exception type.- Extension members are machine-readable, documented and backward-compatible.
- Validation errors use a consistent format.
- Unexpected exceptions are handled centrally.
The type URI does not strictly have to resolve to a public HTML page. It is still useful when the URI is stable over time and can ideally provide documentation. The main rule is that it must not change because of an internal refactoring.
ASP.NET Core and .NET 10 sample
ASP.NET Core has first-class infrastructure for Problem Details. In .NET 10, the basic model remains clear: AddProblemDetails registers shared Problem Details infrastructure, UseExceptionHandler handles unexpected failures, UseStatusCodePages can turn empty status-code responses into Problem Details responses and Minimal APIs can explicitly return TypedResults.Problem or TypedResults.ValidationProblem.
A minimal project looks like this:
1<Project Sdk="Microsoft.NET.Sdk.Web">
2 <PropertyGroup>
3 <TargetFramework>net10.0</TargetFramework>
4 <Nullable>enable</Nullable>
5 <ImplicitUsings>enable</ImplicitUsings>
6 </PropertyGroup>
7</Project>
The following Program.cs shows a complete Minimal API structure with central configuration, a domain error, a validation error and an exception handler.
1using System.Diagnostics;
2using Microsoft.AspNetCore.Diagnostics;
3using Microsoft.AspNetCore.Http.HttpResults;
4using Microsoft.AspNetCore.Mvc;
5
6WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
7
8builder.Services.AddProblemDetails(options =>
9{
10 options.CustomizeProblemDetails = context =>
11 {
12 string traceId = Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
13 context.ProblemDetails.Extensions["traceId"] = traceId;
14
15 if (context.ProblemDetails.Instance is null)
16 {
17 context.ProblemDetails.Instance = context.HttpContext.Request.Path.ToString();
18 }
19 };
20});
21
22builder.Services.AddExceptionHandler<DomainExceptionHandler>();
23
24WebApplication app = builder.Build();
25
26app.UseExceptionHandler();
27app.UseStatusCodePages();
28
29RouteGroupBuilder api = app.MapGroup("/api/v1");
30
31api.MapGet("/orders/{id:guid}", OrderEndpoints.GetOrder)
32 .WithName("GetOrder")
33 .WithTags("Orders")
34 .Produces<OrderResponse>(StatusCodes.Status200OK)
35 .ProducesProblem(StatusCodes.Status404NotFound);
36
37api.MapPost("/orders", OrderEndpoints.CreateOrder)
38 .WithName("CreateOrder")
39 .WithTags("Orders")
40 .Produces<OrderResponse>(StatusCodes.Status201Created)
41 .ProducesValidationProblem()
42 .ProducesProblem(StatusCodes.Status409Conflict);
43
44app.Run();
45
46public static class OrderEndpoints
47{
48 public static Results<Ok<OrderResponse>, ProblemHttpResult> GetOrder(
49 Guid id,
50 HttpContext httpContext)
51 {
52 if (id == KnownOrders.MissingOrderId)
53 {
54 ProblemDetails problem = ProblemFactory.Create(
55 httpContext,
56 StatusCodes.Status404NotFound,
57 ProblemTypes.OrderNotFound,
58 "Order not found",
59 "The requested order does not exist.");
60
61 problem.Extensions["code"] = "order.not_found";
62
63 return TypedResults.Problem(problem);
64 }
65
66 OrderResponse response = new OrderResponse(
67 id,
68 "customer-123",
69 129.90m,
70 "accepted");
71
72 return TypedResults.Ok(response);
73 }
74
75 public static Results<Created<OrderResponse>, ValidationProblem, ProblemHttpResult> CreateOrder(
76 CreateOrderRequest request,
77 HttpContext httpContext)
78 {
79 if (string.IsNullOrWhiteSpace(request.CustomerId) || request.Total <= 0m)
80 {
81 Dictionary<string, string[]> errors = new Dictionary<string, string[]>();
82
83 if (string.IsNullOrWhiteSpace(request.CustomerId))
84 {
85 errors["customerId"] = new[] { "CustomerId is required." };
86 }
87
88 if (request.Total <= 0m)
89 {
90 errors["total"] = new[] { "Total must be greater than zero." };
91 }
92
93 Dictionary<string, object?> extensions = new Dictionary<string, object?>
94 {
95 ["code"] = "request.validation_failed",
96 ["traceId"] = Activity.Current?.Id ?? httpContext.TraceIdentifier
97 };
98
99 return TypedResults.ValidationProblem(
100 errors,
101 detail: "The request body contains invalid values.",
102 instance: httpContext.Request.Path.ToString(),
103 statusCode: StatusCodes.Status400BadRequest,
104 title: "Validation failed",
105 type: ProblemTypes.ValidationFailed,
106 extensions: extensions);
107 }
108
109 if (request.CustomerId == "blocked-customer")
110 {
111 throw new DomainException(
112 "customer.blocked",
113 "The customer is currently blocked for new orders.");
114 }
115
116 Guid orderId = Guid.NewGuid();
117
118 OrderResponse response = new OrderResponse(
119 orderId,
120 request.CustomerId,
121 request.Total,
122 "created");
123
124 return TypedResults.Created($"/api/v1/orders/{orderId}", response);
125 }
126}
127
128public sealed record CreateOrderRequest(
129 string CustomerId,
130 decimal Total);
131
132public sealed record OrderResponse(
133 Guid Id,
134 string CustomerId,
135 decimal Total,
136 string Status);
137
138public static class ProblemTypes
139{
140 public const string OrderNotFound = "https://api.example.com/problems/order-not-found";
141 public const string ValidationFailed = "https://api.example.com/problems/validation-failed";
142 public const string CustomerBlocked = "https://api.example.com/problems/customer-blocked";
143}
144
145public static class KnownOrders
146{
147 public static readonly Guid MissingOrderId = Guid.Parse("00000000-0000-0000-0000-000000000404");
148}
149
150public static class ProblemFactory
151{
152 public static ProblemDetails Create(
153 HttpContext httpContext,
154 int statusCode,
155 string type,
156 string title,
157 string detail)
158 {
159 ProblemDetails problem = new ProblemDetails
160 {
161 Type = type,
162 Title = title,
163 Status = statusCode,
164 Detail = detail,
165 Instance = httpContext.Request.Path.ToString()
166 };
167
168 problem.Extensions["traceId"] = Activity.Current?.Id ?? httpContext.TraceIdentifier;
169
170 return problem;
171 }
172}
173
174public sealed class DomainException(string code, string message) : Exception(message)
175{
176 public string Code { get; } = code;
177}
178
179public sealed class DomainExceptionHandler(IProblemDetailsService problemDetailsService) : IExceptionHandler
180{
181 public async ValueTask<bool> TryHandleAsync(
182 HttpContext httpContext,
183 Exception exception,
184 CancellationToken cancellationToken)
185 {
186 if (exception is not DomainException domainException)
187 {
188 return false;
189 }
190
191 httpContext.Response.StatusCode = StatusCodes.Status409Conflict;
192
193 ProblemDetails problem = ProblemFactory.Create(
194 httpContext,
195 StatusCodes.Status409Conflict,
196 ProblemTypes.CustomerBlocked,
197 "Customer blocked",
198 "The order cannot be created for this customer.");
199
200 problem.Extensions["code"] = domainException.Code;
201
202 ProblemDetailsContext problemDetailsContext = new ProblemDetailsContext
203 {
204 HttpContext = httpContext,
205 ProblemDetails = problem,
206 Exception = exception
207 };
208
209 return await problemDetailsService.TryWriteAsync(problemDetailsContext);
210 }
211}
A GET request for a missing order no longer returns a proprietary error structure. It returns a standardized response:
1{
2 "type": "https://api.example.com/problems/order-not-found",
3 "title": "Order not found",
4 "status": 404,
5 "detail": "The requested order does not exist.",
6 "instance": "/api/v1/orders/00000000-0000-0000-0000-000000000404",
7 "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00",
8 "code": "order.not_found"
9}
The sample contains two important patterns.
First, expected domain failures are modeled explicitly. A missing object is not a technical exception. It is a normal API outcome with 404 and a stable problem type.
Second, unexpected or central domain exceptions are translated at the API boundary. The exception handler decides which public details are visible. Internal exception messages stay out of the API response.
Client-side processing
A client application should treat Problem Details as a data structure. The following excerpt shows the basic pattern with HttpClient: successful responses are read normally, while error responses are analyzed as Problem Details.
1using System.Net.Http.Json;
2using System.Text.Json;
3using System.Text.Json.Serialization;
4
5public sealed class OrdersClient(HttpClient httpClient)
6{
7 public async Task<CreateOrderResult> CreateOrderAsync(
8 CreateOrderRequest request,
9 CancellationToken cancellationToken)
10 {
11 HttpResponseMessage response = await httpClient.PostAsJsonAsync(
12 "/api/v1/orders",
13 request,
14 cancellationToken);
15
16 if (response.IsSuccessStatusCode)
17 {
18 OrderResponse? order = await response.Content.ReadFromJsonAsync<OrderResponse>(cancellationToken);
19 return order is not null
20 ? CreateOrderResult.Created(order)
21 : CreateOrderResult.Failed("client.empty_success_response");
22 }
23
24 ApiProblemDetails? problem = await response.Content.ReadFromJsonAsync<ApiProblemDetails>(cancellationToken);
25
26 if (problem?.Type == ProblemTypes.ValidationFailed)
27 {
28 return CreateOrderResult.Failed("request.validation_failed");
29 }
30
31 if (problem?.Extensions.TryGetValue("code", out JsonElement codeElement) == true)
32 {
33 string? code = codeElement.GetString();
34
35 if (code == "customer.blocked")
36 {
37 return CreateOrderResult.Failed("customer.blocked");
38 }
39 }
40
41 return CreateOrderResult.Failed("client.unhandled_problem");
42 }
43}
44
45public sealed record ApiProblemDetails
46{
47 public string? Type { get; init; }
48 public string? Title { get; init; }
49 public int? Status { get; init; }
50 public string? Detail { get; init; }
51 public string? Instance { get; init; }
52
53 [JsonExtensionData]
54 public IDictionary<string, JsonElement> Extensions { get; init; } = new Dictionary<string, JsonElement>();
55}
56
57public sealed record CreateOrderResult
58{
59 private CreateOrderResult(OrderResponse? order, string? errorCode)
60 {
61 Order = order;
62 ErrorCode = errorCode;
63 }
64
65 public OrderResponse? Order { get; }
66 public string? ErrorCode { get; }
67
68 public static CreateOrderResult Created(OrderResponse order)
69 {
70 return new CreateOrderResult(order, null);
71 }
72
73 public static CreateOrderResult Failed(string errorCode)
74 {
75 return new CreateOrderResult(null, errorCode);
76 }
77}
This client does not make decisions based on detail. It uses type and code. The API can improve, localize, or shorten human-readable text without breaking client logic.
Common mistakes with Problem Details
Problem Details can still be used poorly. The most common issues appear when the format is treated as only a nicer JSON wrapper.
An unstable type value is a problem. If the URI contains class names, internal namespaces, or ticket numbers, it quickly becomes useless after refactorings or process changes. Product-oriented or domain-oriented names such as /problems/order-not-found are better.
Too much detail is another mistake. detail is not the place for stack traces, connection strings, SQL statements, or raw downstream service responses. Those belong in logs and traces. The API response needs enough context for meaningful external handling, but not the complete internal diagnosis.
A third mistake is mixing localization with machine logic. Localized messages are useful for user interfaces, but poor technical keys. Stable codes and problem types stay language-independent.
Conclusion
Problem Details does not make HTTP errors more complicated. It removes their randomness.
For APIs, it creates a consistent, documentable and safe error contract. For client applications, it makes errors predictable and actionable because they no longer need to interpret free text. ASP.NET Core and .NET 10 already provide the infrastructure for a clean start: AddProblemDetails, UseExceptionHandler, UseStatusCodePages, TypedResults.Problem and TypedResults.ValidationProblem are enough for a solid baseline.
The most important design decision remains domain-specific: problem types and error codes must be stable, documented and chosen deliberately. Then Problem Details becomes more than a JSON format. It becomes a shared error language between API, client, operations and support.
Related articles

Jul 14, 2026 - 10 min read
Using MeterListener with the new .NET 11 MemoryCache metrics
.NET 11 adds a small but useful improvement to the built-in observability story: MemoryCache can now publish OpenTelemetry-compatible …

Jul 10, 2026 - 11 min read
Top 10 recommendations for .NET developers working with Azure Cosmos DB
Azure Cosmos DB has a habit of exposing architectural weaknesses very early. Classic data-access patterns that work perfectly fine with …

Jul 05, 2026 - 7 min read
How to migrate .NET versioning from x.x.x.x to SemVer
Many long-lived .NET repositories did not start with semantic versioning. They grew up with major.minor.build.revision, often set in …
Let's Work Together
Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.
