Problem Details in ASP.NET Core and .NET 10

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 problem
  • title: a short human-readable summary of the problem type
  • status: the HTTP status code
  • detail: a concrete description of this specific occurrence
  • instance: 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 traceId without 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:

  • type as the standard identifier for the problem type
  • an additional code field as a compact domain key
  • errors or similar extension members for validation details
  • traceId for 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 type URI.
  • status matches the actual HTTP status code.
  • title stays short and stable for a given problem type.
  • detail describes only the concrete occurrence and contains no confidential data.
  • instance identifies 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.


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.

New Platforms
Modernization
Training & Consulting