Problem Details and RFC 9457 in ASP.NET Core
Every API needs a consistent error format. Without one, clients get a mix of plain text messages, HTML error pages, and ad-hoc JSON objects depending on what went wrong and where. RFC 9457 (formerly RFC 7807) defines Problem Details — a standard JSON structure for conveying machine-readable error information from HTTP APIs.
The Problem Details Format
A Problem Details response looks like this:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"detail": "Product with ID 42 was not found.",
"instance": "/api/products/42"
}
The key fields are:
- type — a URI identifying the problem type
- title — a short, human-readable summary
- status — the HTTP status code
- detail — a human-readable explanation specific to this occurrence
- instance — a URI identifying this specific occurrence
Enabling Problem Details in ASP.NET Core
ASP.NET Core 7+ has built-in support. Enable it with a single line:
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
Now unhandled exceptions return Problem Details JSON instead of HTML error pages, and empty status code responses (like a bare 404) get a Problem Details body.
Returning Problem Details from Controllers
The ControllerBase class includes helper methods:
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
private readonly IProductRepository _repository;
public ProductsController(IProductRepository repository)
{
_repository = repository;
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var product = await _repository.GetByIdAsync(id);
if (product is null)
{
return Problem(
detail: $"Product with ID {id} was not found.",
statusCode: StatusCodes.Status404NotFound,
title: "Product Not Found");
}
return Ok(product);
}
[HttpPost]
public async Task<IActionResult> Create(CreateProductRequest request)
{
if (await _repository.ExistsBySkuAsync(request.Sku))
{
return Problem(
detail: $"A product with SKU '{request.Sku}' already exists.",
statusCode: StatusCodes.Status409Conflict,
title: "Duplicate SKU");
}
var product = await _repository.CreateAsync(request);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
}
Returning Problem Details from Minimal APIs
Use TypedResults.Problem or Results.Problem:
app.MapGet("/api/products/{id}", async (int id, IProductRepository repository) =>
{
var product = await repository.GetByIdAsync(id);
return product is not null
? Results.Ok(product)
: Results.Problem(
detail: $"Product with ID {id} was not found.",
statusCode: StatusCodes.Status404NotFound,
title: "Product Not Found");
});
Validation Errors
The [ApiController] attribute automatically returns validation errors as Problem Details with the application/problem+json content type. The response uses the ValidationProblemDetails subclass, which includes an errors dictionary:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["The Name field is required."],
"Price": ["The Price field must be greater than 0."]
}
}
Custom Problem Types
Define your own problem types for domain-specific errors:
public static class ProblemTypes
{
public const string InsufficientStock = "https://api.example.com/problems/insufficient-stock";
public const string PaymentDeclined = "https://api.example.com/problems/payment-declined";
}
[HttpPost("orders")]
public IActionResult PlaceOrder(PlaceOrderRequest request)
{
if (stock < request.Quantity)
{
return Problem(
type: ProblemTypes.InsufficientStock,
detail: $"Requested {request.Quantity} units but only {stock} available.",
statusCode: StatusCodes.Status422UnprocessableEntity,
title: "Insufficient Stock");
}
// ...
}
Customising the Problem Details Service
Use CustomizeProblemDetails to add information to every Problem Details response:
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Instance =
$"{context.HttpContext.Request.Method} {context.HttpContext.Request.Path}";
context.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
context.ProblemDetails.Extensions["timestamp"] = DateTime.UtcNow;
};
});
Every error response now includes the trace ID and timestamp, making support requests much easier to trace.
Exception Handling with Problem Details
Map specific exceptions to Problem Details responses:
app.UseExceptionHandler(exceptionApp =>
{
exceptionApp.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
var problemDetails = exception switch
{
NotFoundException ex => new ProblemDetails
{
Status = StatusCodes.Status404NotFound,
Title = "Resource Not Found",
Detail = ex.Message
},
ConflictException ex => new ProblemDetails
{
Status = StatusCodes.Status409Conflict,
Title = "Conflict",
Detail = ex.Message
},
_ => new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Internal Server Error",
Detail = "An unexpected error occurred."
}
};
context.Response.StatusCode = problemDetails.Status ?? 500;
await context.Response.WriteAsJsonAsync(problemDetails);
});
});
Problem Details isn't glamorous, but it transforms your API's error handling from ad-hoc to consistent. Clients can parse errors reliably, support teams can trace issues efficiently, and your API behaves predictably regardless of what goes wrong.