Problem Details in ASP.NET Core and .NET 11

builder.Services.AddProblemDetails() registers infrastructure. It does not create an error contract.

An ASP.NET Core request can fail during binding, validation, authentication, authorization, endpoint execution, exception handling, routing or rate limiting. Without one design, those paths return different media types, identifiers and amounts of detail. Clients still need special cases even though some responses happen to use the ProblemDetails class.

Every actionable error should have one stable RFC 9457 type, one correct HTTP status, documented extensions and no internal diagnostic data. Every producer must converge on that contract.

Release status: The .NET 11 details reflect Preview 7 from August 2026. The core Problem Details APIs are established; preview validation behavior still needs verification against the final release.

One contract, several producers

Failure sourceASP.NET Core mechanismExample
Expected domain outcomeExplicit TypedResults.ProblemOrder version conflict
BindingMinimal API parameter and JSON bindingMalformed JSON or invalid parameter
Model validationMicrosoft.Extensions.ValidationData Annotation failure
Known technical exceptionIExceptionHandlerDependency unavailable
Empty 4xx or 5xxUseStatusCodePagesUnmatched route

AddProblemDetails connects framework code to IProblemDetailsService. It does not translate application exceptions or decide whether an invalid state is 409, 412 or 422.

.NET 11 improves this path with asynchronous Data Annotations, shared validation localization, corrected validation metadata and content types, safer extension handling and an RFC-aligned default title for 500. Those changes improve consistency; the application still owns the taxonomy.

type is the machine contract

RFC 9457 defines five standard members:

MemberPurpose
typeStable identity of the problem class
titleHuman-readable summary, stable except localization
statusAdvisory copy of the HTTP status
detailHuman-readable text for this occurrence
instanceURI identifying this occurrence

Clients branch on type, not on title or detail. A custom code extension can provide a compact alias, but it must describe the same taxonomy.

When type is omitted, its semantic value is about:blank. That is acceptable for a generic route-level 404. It is too weak for an order conflict that requires client-specific recovery.

Keep problem types in one catalog

Type URIs should not be constructed from exception names or scattered across endpoints.

 1using Microsoft.AspNetCore.Mvc;
 2
 3public static class ApiProblemTypes
 4{
 5    private const string BaseUri = "https://api.example.com/problems";
 6
 7    public const string OrderNotFound = BaseUri + "/order-not-found";
 8    public const string OrderVersionConflict = BaseUri + "/order-version-conflict";
 9    public const string OrderStateInvalid = BaseUri + "/order-state-invalid";
10    public const string DependencyUnavailable = BaseUri + "/dependency-unavailable";
11    public const string ValidationFailed = BaseUri + "/validation-failed";
12}
13
14public static class ApiProblems
15{
16    public static ProblemDetails OrderNotFound(Guid orderId)
17    {
18        ProblemDetails problem = new ProblemDetails
19        {
20            Type = ApiProblemTypes.OrderNotFound,
21            Title = "Order not found",
22            Status = StatusCodes.Status404NotFound,
23            Detail = "The requested order does not exist."
24        };
25
26        problem.Extensions["code"] = "order.not_found";
27        problem.Extensions["orderId"] = orderId;
28
29        return problem;
30    }
31
32    public static ProblemDetails OrderVersionConflict(long currentVersion)
33    {
34        ProblemDetails problem = new ProblemDetails
35        {
36            Type = ApiProblemTypes.OrderVersionConflict,
37            Title = "Order version conflict",
38            Status = StatusCodes.Status409Conflict,
39            Detail = "The order changed after it was loaded. Reload it before retrying the update."
40        };
41
42        problem.Extensions["code"] = "order.version_conflict";
43        problem.Extensions["currentVersion"] = currentVersion;
44
45        return problem;
46    }
47
48    public static ProblemDetails OrderStateInvalid(string currentState)
49    {
50        ProblemDetails problem = new ProblemDetails
51        {
52            Type = ApiProblemTypes.OrderStateInvalid,
53            Title = "Order state does not allow this operation",
54            Status = StatusCodes.Status422UnprocessableEntity,
55            Detail = "The requested transition is not valid for the current order state."
56        };
57
58        problem.Extensions["code"] = "order.state_invalid";
59        problem.Extensions["currentState"] = currentState;
60
61        return problem;
62    }
63}

The factory controls identity, status, safe wording and extension schema. Application code supplies only occurrence-specific values.

Type URIs should resolve to maintained documentation when practical. That page explains meaning, status, extension fields and types, retry behavior and versioning. The URI remains stable when a class or team is renamed.

HTTP status still comes first

Problem Details extends HTTP; it does not repair a wrong status code.

StatusRecommended meaning
400Malformed syntax, binding or request validation
401Missing or invalid credentials
403Authenticated caller lacks permission
404Route or resource unavailable to the caller
409Conflict with current resource state or version
412Explicit HTTP precondition such as If-Match failed
422Valid syntax violates a semantic rule
429Rate policy exhausted, normally with Retry-After
503Temporary service inability

A stale application version is normally 409. A failed ETag in If-Match is 412. An impossible state transition can be 422. These differences affect generic clients, caches, retries, metrics and generated SDKs.

Expected failures stay out of exception handling

Not found, version conflict and invalid state are normal outcomes and should be modeled explicitly:

 1public abstract record UpdateOrderResult
 2{
 3    private UpdateOrderResult()
 4    {
 5    }
 6
 7    public sealed record Updated(OrderResponse Order) : UpdateOrderResult;
 8    public sealed record NotFound : UpdateOrderResult;
 9    public sealed record VersionConflict(long CurrentVersion) : UpdateOrderResult;
10    public sealed record InvalidState(string CurrentState) : UpdateOrderResult;
11}

The API adapter maps domain results to HTTP once:

 1using Microsoft.AspNetCore.Http.HttpResults;
 2
 3public static class OrderEndpoints
 4{
 5    public static async Task<Results<Ok<OrderResponse>, ProblemHttpResult>> UpdateAsync(
 6        Guid orderId,
 7        UpdateOrderRequest request,
 8        IOrderService orderService,
 9        CancellationToken cancellationToken)
10    {
11        UpdateOrderResult result = await orderService.UpdateAsync(
12            orderId,
13            request,
14            cancellationToken);
15
16        if (result is UpdateOrderResult.Updated updated)
17        {
18            return TypedResults.Ok(updated.Order);
19        }
20
21        if (result is UpdateOrderResult.NotFound)
22        {
23            return TypedResults.Problem(ApiProblems.OrderNotFound(orderId));
24        }
25
26        if (result is UpdateOrderResult.VersionConflict conflict)
27        {
28            return TypedResults.Problem(
29                ApiProblems.OrderVersionConflict(conflict.CurrentVersion));
30        }
31
32        UpdateOrderResult.InvalidState invalidState =
33            (UpdateOrderResult.InvalidState)result;
34
35        return TypedResults.Problem(
36            ApiProblems.OrderStateInvalid(invalidState.CurrentState));
37    }
38}

The application service does not know ProblemDetails; the endpoint does not reimplement domain rules.

OpenAPI metadata lists every expected result:

1orders.MapPut("/{orderId:guid}", OrderEndpoints.UpdateAsync)
2    .WithName("UpdateOrder")
3    .Produces<OrderResponse>(StatusCodes.Status200OK)
4    .ProducesProblem(StatusCodes.Status404NotFound)
5    .ProducesProblem(StatusCodes.Status409Conflict)
6    .ProducesProblem(StatusCodes.Status422UnprocessableEntity)
7    .ProducesValidationProblem(StatusCodes.Status400BadRequest);

Add occurrence data once

Trace correlation and instance generation belong in the global customization point:

 1using System.Diagnostics;
 2
 3builder.Services.AddProblemDetails(options =>
 4{
 5    options.CustomizeProblemDetails = context =>
 6    {
 7        string traceId = Activity.Current?.TraceId.ToString()
 8            ?? context.HttpContext.TraceIdentifier;
 9
10        context.ProblemDetails.Extensions["traceId"] = traceId;
11
12        if (context.ProblemDetails is HttpValidationProblemDetails validation)
13        {
14            validation.Type = ApiProblemTypes.ValidationFailed;
15            validation.Title = "Request validation failed";
16        }
17
18        if (context.ProblemDetails.Instance is null)
19        {
20            string escapedTraceId = Uri.EscapeDataString(traceId);
21            context.ProblemDetails.Instance =
22                $"/problem-instances/{escapedTraceId}";
23        }
24    };
25});

A request path is a valid instance URI, but it does not distinguish repeated failures. The trace-based URI above provides a correlation reference; a trace can contain several failing requests, so a separate occurrence ID is needed if every problem must have a unique URI.

Global extensions should remain small and invariant. User IDs, tokens, exception text, stack traces, SQL, node names and full URLs do not belong in a public error body.

Wire every framework path

 1using Microsoft.AspNetCore.Diagnostics;
 2
 3WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
 4
 5builder.Services.AddProblemDetails(options =>
 6{
 7    options.CustomizeProblemDetails = ProblemDetailsCustomization.Apply;
 8});
 9
10builder.Services.AddValidation();
11builder.Services.AddExceptionHandler<DependencyExceptionHandler>();
12
13WebApplication app = builder.Build();
14
15app.UseExceptionHandler();
16app.UseStatusCodePages();
17
18app.MapOrderEndpoints();
19
20app.Run();

UseStatusCodePages fills an empty 4xx or 5xx body. It does not replace explicit Problem Details already written by an endpoint. It should run before the middleware whose empty errors it must observe, including authentication and authorization when their responses need a body.

The body supplements protocol signals. A 401 still needs WWW-Authenticate; a 429 or 503 may need Retry-After.

AddValidation validates successfully bound models; it does not perform JSON or parameter binding. Binding failures can produce an empty 400 handled by status-code pages or throw in development when ThrowOnBadRequest is enabled. MVC controller validation uses ModelState and ApiBehaviorOptions. Those paths need separate mappings and tests if they must share the custom validation type above.

Translate known exceptions narrowly

An exception handler exposes a safe technical problem and keeps diagnostics internal:

 1using Microsoft.AspNetCore.Diagnostics;
 2using Microsoft.AspNetCore.Mvc;
 3
 4public sealed class DependencyExceptionHandler(
 5    IProblemDetailsService problemDetailsService,
 6    ILogger<DependencyExceptionHandler> logger) : IExceptionHandler
 7{
 8    public async ValueTask<bool> TryHandleAsync(
 9        HttpContext httpContext,
10        Exception exception,
11        CancellationToken cancellationToken)
12    {
13        if (exception is not InventoryUnavailableException dependencyException)
14        {
15            return false;
16        }
17
18        logger.LogWarning(
19            dependencyException,
20            "Inventory dependency was unavailable while processing {TraceId}",
21            httpContext.TraceIdentifier);
22
23        httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
24        httpContext.Response.Headers.RetryAfter = "30";
25
26        ProblemDetails problem = new ProblemDetails
27        {
28            Type = ApiProblemTypes.DependencyUnavailable,
29            Title = "Inventory service unavailable",
30            Status = StatusCodes.Status503ServiceUnavailable,
31            Detail = "Inventory information is temporarily unavailable."
32        };
33
34        problem.Extensions["code"] = "dependency.inventory_unavailable";
35
36        ProblemDetailsContext problemContext = new ProblemDetailsContext
37        {
38            HttpContext = httpContext,
39            ProblemDetails = problem,
40            Exception = exception
41        };
42
43        bool written = await problemDetailsService.TryWriteAsync(problemContext);
44
45        if (!written)
46        {
47            httpContext.Response.ContentType = "text/plain";
48            await httpContext.Response.WriteAsync(
49                "Inventory information is temporarily unavailable.",
50                cancellationToken);
51        }
52
53        return true;
54    }
55}

Returning false passes an unknown exception to the next registered handler. Narrow handlers run before a generic 500 fallback. exception.Message must never be copied into detail.

Since .NET 10, a handler returning true suppresses the exception middleware’s diagnostics by default. The explicit warning above remains. Applications that also need the middleware’s exception diagnostics can configure ExceptionHandlerOptions.SuppressDiagnosticsCallback; logging and metrics should be tested to avoid both missing and duplicated signals.

Content negotiation needs a decision

The default Problem Details writer handles JSON-compatible Accept values, including application/problem+json. It does not automatically write XML or HTML. TryWriteAsync returns false when no writer can satisfy the request, which is why middleware handlers need a safe fallback.

TypedResults.Problem behaves slightly differently: after asking IProblemDetailsService, it can serialize its own value as application/problem+json. Tests should cover explicit endpoint errors and middleware-generated errors separately.

Registering XML formatters for normal responses does not create an application/problem+xml writer. XML support requires a deliberate IProblemDetailsWriter and contract tests.

An API should document one unsupported-representation policy: safe text, empty body or 406. Accidental formatter order is not a policy.

Validation has one stable shape

Validation combines several field failures under one problem type. Whether the API uses the .NET dictionary shape or an array of JSON Pointers, that schema becomes a client contract.

 1{
 2  "type": "https://api.example.com/problems/validation-failed",
 3  "title": "Request validation failed",
 4  "status": 400,
 5  "instance": "/problem-instances/9fb9e248d6924a22a26427ea769a8f01",
 6  "traceId": "9fb9e248d6924a22a26427ea769a8f01",
 7  "errors": {
 8    "customerId": ["CustomerId is required."],
 9    "total": ["Total must be greater than zero."]
10  }
11}

.NET 11 async validation allows I/O before the endpoint runs. It still requires cancellation, short timeouts, bounded concurrency and database enforcement for race-sensitive invariants. A uniqueness check can improve the response; it cannot replace a unique constraint.

Localized messages remain presentation. Client logic uses type and stable extension names.

Detail must not leak or carry machine data

Resource concealment must be consistent. If an inaccessible order returns 404, a type named order-forbidden or an ownerId extension defeats the concealment.

Distinctions that reveal registered email addresses, unknown usernames, internal dependency hosts, database objects, server paths or unrestricted echoed input should also be avoided.

Programmatic values belong in documented extensions:

1{
2  "type": "https://api.example.com/problems/order-version-conflict",
3  "title": "Order version conflict",
4  "status": 409,
5  "detail": "The order changed after it was loaded. Reload it before retrying the update.",
6  "code": "order.version_conflict",
7  "currentVersion": 17
8}

The client reads currentVersion, not a number parsed from an English sentence. That leaves detail free to improve or localize.

OpenAPI and telemetry serve different consumers

.ProducesProblem documents the base shape. If clients depend on currentVersion, a retry field or a custom validation schema, OpenAPI needs a dedicated schema or transformer for those extensions.

ASP.NET Core 11 generates OpenAPI 3.2 by default. Pinning 3.1 while a generator catches up is reasonable; changing the document version must not change runtime error semantics.

Telemetry should use problem type, status, endpoint name and bounded outcome as metric dimensions. Trace ID, instance, resource IDs and validation values belong in traces or controlled logs, not metrics.

The trace ID connects the safe public response to internal exception details without copying those details to the client.

HTTP contract tests are mandatory

A factory unit test cannot detect missing middleware, the wrong media type, an overwritten status or an empty route-level 404.

 1using System.Net;
 2using System.Text.Json;
 3using Microsoft.AspNetCore.Mvc.Testing;
 4using Xunit;
 5
 6public sealed class OrderApiProblemDetailsTests(
 7    WebApplicationFactory<Program> application)
 8    : IClassFixture<WebApplicationFactory<Program>>
 9{
10    [Fact]
11    public async Task OrderApi_GetMissingOrder_ReturnsDocumentedProblemDetails()
12    {
13        HttpClient client = application.CreateClient();
14        CancellationToken cancellationToken =
15            TestContext.Current.CancellationToken;
16
17        using HttpRequestMessage request = new HttpRequestMessage(
18            HttpMethod.Get,
19            "/api/v1/orders/00000000-0000-0000-0000-000000000404");
20
21        request.Headers.Accept.ParseAdd("application/problem+json");
22
23        using HttpResponseMessage response = await client.SendAsync(
24            request,
25            cancellationToken);
26
27        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
28        Assert.Equal(
29            "application/problem+json",
30            response.Content.Headers.ContentType?.MediaType);
31
32        await using Stream content = await response.Content.ReadAsStreamAsync(
33            cancellationToken);
34
35        using JsonDocument document = await JsonDocument.ParseAsync(
36            content,
37            cancellationToken: cancellationToken);
38
39        JsonElement root = document.RootElement;
40
41        Assert.Equal(
42            ApiProblemTypes.OrderNotFound,
43            root.GetProperty("type").GetString());
44        Assert.Equal(404, root.GetProperty("status").GetInt32());
45        Assert.Equal(
46            "order.not_found",
47            root.GetProperty("code").GetString());
48        Assert.True(root.TryGetProperty("traceId", out JsonElement traceId));
49        Assert.False(string.IsNullOrWhiteSpace(traceId.GetString()));
50    }
51}

The test matrix should cover domain results, malformed JSON, synchronous and asynchronous validation, 401, 403, known and unknown exceptions, unmatched routes, supported and unsupported Accept values, localization and OpenAPI schemas.

Negative assertions are equally important: no exception message, stack trace, internal URL, server path, token or concealed identifier may appear.

Version the semantics, not the wording

Adding an optional extension is normally compatible because clients ignore unknown members. Changing a type URI, status, required extension or extension data type is not.

One URI must never be reused for new semantics. A materially different failure receives a new problem type. title and detail can evolve as human-readable text; machine identifiers cannot.

Clients first use the HTTP status for generic protocol behavior, then type for domain behavior and safely fall back for unknown types. The actual HTTP status controls protocol behavior when it disagrees with the advisory body field.

Design rule

Problem Details becomes useful when every layer tells the same precise and safe story about failure. The class name and JSON shape are the easy part.

The real design is a maintained type catalog, correct HTTP semantics, one mapping from domain outcomes, narrow exception translation, bounded extensions, explicit negotiation, accurate OpenAPI and HTTP-level tests. Once those pieces agree, clients stop parsing English messages and the error contract can evolve without surprises.

The foundational introduction remains available in Problem Details in ASP.NET Core and .NET 10 . Normative behavior is defined by RFC 9457 and framework integration is documented in Handle errors in ASP.NET Core APIs . The exception-handling documentation explains diagnostics suppression for handled exceptions.

What Matters in ASP.NET Core 11

Sep 14, 2026 - 10 min read

What Matters in ASP.NET Core 11

ASP.NET Core 11 has no single feature that fundamentally changes how web applications are built. That is not a criticism. Mature frameworks …


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