When building APIs, error responses are easy to overlook until clients start complaining.
One endpoint returns a plain string. Another returns a random anonymous object. A third returns nothing but a status code.
That inconsistency makes error handling painful on the client side and hard to document on the server side.
To address this, ASP.NET Core gives us first-class support for Problem Details, a standard machine-readable error format for HTTP APIs.
Problem Details
Problem Details is defined by RFC 9457, the successor to RFC 7807.
It describes a consistent JSON shape for HTTP error responses, typically returned with the application/problem+json media type.
A typical 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": "/products/42"
}
The core fields are:
- type - A URI that identifies the problem type
- title - A short, human-readable summary
- status - The HTTP status code
- detail - A more specific explanation for this occurrence
- instance - A URI that identifies this specific occurrence
You can also add custom extensions when you need more context, such as an error code or a trace id.
Getting Started
ASP.NET Core already ships with Problem Details support. You do not need an extra NuGet package.
Start by registering the services and enabling a couple of middlewares:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.Run();
AddProblemDetails registers the default IProblemDetailsService, which ASP.NET Core uses to write RFC-compliant error responses.
UseExceptionHandler catches unhandled exceptions and can return them as Problem Details.
UseStatusCodePages converts empty error responses, like a bare 404, into a Problem Details body.
Together, these three give you a solid baseline for consistent API errors.
Returning Problem Details
For expected failures you should return Problem Details explicitly from the endpoint.
In Minimal APIs, that usually means Results.Problem or TypedResults.Problem:
app.MapGet("/products/{id:guid}", async (
Guid id,
ApplicationDbContext dbContext) =>
{
var product = await dbContext.Products
.FirstOrDefaultAsync(p => p.Id == id);
if (product is null)
{
return Results.Problem(
title: "Product not found",
detail: $"Product with id {id} was not found.",
statusCode: StatusCodes.Status404NotFound);
}
return Results.Ok(product);
});
For validation failures, prefer Results.ValidationProblem. It returns a ValidationProblemDetails response with an errors dictionary.
app.MapPost("/products", (CreateProductRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["Name"] = ["Name is required."]
});
}
return Results.Ok();
});
In controllers, you get the same idea through ControllerBase.Problem and ValidationProblem:
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var product = await _dbContext.Products
.FirstOrDefaultAsync(p => p.Id == id);
if (product is null)
{
return Problem(
title: "Product not found",
detail: $"Product with id {id} was not found.",
statusCode: StatusCodes.Status404NotFound);
}
return Ok(product);
}
Expected errors stay in the endpoint. Unexpected exceptions should still go through a global handler.
If you want built-in request validation for Minimal APIs, check out my previous blog post: Minimal API Validation in ASP.NET Core.
Customization
Sometimes the default fields are not enough. You may want a trace id, a correlation id or an application-specific error code on every response.
That's where CustomizeProblemDetails comes in:
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
context.HttpContext.TraceIdentifier;
};
});
Now framework-generated Problem Details responses can include the extra field automatically.
You can also set extension values when returning from an endpoint:
return Results.Problem(new ProblemDetails
{
Title = "Product not found",
Detail = $"Product with id {id} was not found.",
Status = StatusCodes.Status404NotFound,
Extensions =
{
["code"] = "product.not_found"
}
});
This keeps the shared contract intact while still giving clients the extra details they need.
Global Exception Handling
Expected failures belong in your endpoints. Unexpected failures belong in one place.
With IExceptionHandler and IProblemDetailsService, you can map exceptions to Problem Details without duplicating response logic:
public sealed class GlobalExceptionHandler(
IProblemDetailsService problemDetailsService,
ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
logger.LogError(exception, "Unhandled exception");
httpContext.Response.StatusCode =
StatusCodes.Status500InternalServerError;
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Title = "Server Error",
Detail = "An unexpected error occurred.",
Status = StatusCodes.Status500InternalServerError
}
});
}
}
Register the handler and keep UseExceptionHandler in the pipeline:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
Using IProblemDetailsService keeps customization such as CustomizeProblemDetails in play, so your exception responses stay consistent with everything else.
I've written a dedicated blog post on global exception handling that you can check out here if you're interested: Global Exception Handling in .NET.
For expected business failures without throwing, the Result Pattern pairs really well with Problem Details at the API boundary.
Conclusion
Problem Details give your API a clear and predictable error contract.
Enable them early with AddProblemDetails, return them explicitly for expected failures and map unexpected exceptions through a global handler.
Once every error follows the same shape, clients become easier to write and your API becomes easier to evolve.
If you want to check out examples I created, you can find the source code here:
Source CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!
