Updated on
The ProblemDetails class is how ASP.NET Core and C# represent the Problem Details object, the standard format for API errors that RFC 9457 defines (it replaced RFC 7807). It plays a role in standardizing error and exception handling in our ASP.NET Core APIs.
Let’s get started!
What Is the ProblemDetails Class in ASP.NET Core?
ProblemDetails is the class ASP.NET Core uses for a problem details object, the JSON body that RFC 9457 defines for HTTP API errors. It has a property for each of the five standard members, Type, Title, Status, Detail and Instance, plus an Extensions dictionary for any custom information we want to add.
A response that carries one uses the application/problem+json content type. So a client can recognize an error body before it reads a single member, even if it knows nothing else about our API.
We rarely create the class by hand. In minimal APIs, TypedResults.Problem() builds one, and in controllers, Problem() does the same. Validation failures use a subclass that adds an errors dictionary with the messages for each field.
Once we call builder.Services.AddProblemDetails(), the exception handler and the status code pages middleware write the format too. Every body the service writes also gets a traceId, which ties the error to its request.
The Need for Standardization
For communicating the errors and exceptions to our API clients, we should specify a response format. In some cases, we would also like to let our users know what actually happened when something went wrong, instead of just telling them it was a 404 or 500 error.
If multiple clients consume our API, or if we need to use a selection of someone else’s APIs, it saves a lot of headaches to have this communication standardized.
IETF RFC 7807 and RFC 9457
IETF to the rescue! The IETF RFC 7807 document addressed this topic and created a standardized format by specifying the Problem Details object. In July 2023, RFC 9457 replaced it and kept the same format.
We will explore what that object looks like in a bit more detail and how and where we use it in our ASP.NET Core Web APIs. We are also going to try out a few things from our example project.
Is RFC 7807 Still the Standard?
No. RFC 9457 replaced it in July 2023, and its abstract says so in one line: “This document obsoletes RFC 7807.” So when we see RFC 7807 in older documentation, blog posts or code comments, we can read it as RFC 9457.
The format itself didn’t change. The five members mean what they meant, extension members work the same way, and the media type is still application/problem+json. An API that followed RFC 7807 still produces valid problem details today.
The new RFC lists three changes in its appendix. It creates a registry of common problem types, so different APIs can share type URIs. It says what to do when a request has several different problems, which is to report the most relevant or urgent one. And it gives guidance for type URIs that nobody can open in a browser.
The ProblemDetails class in ASP.NET Core fits both versions, because the shape is the same.
We can read the whole standard at the RFC Editor, as RFC 9457: Problem Details for HTTP APIs. The shared registry lives at IANA, as the HTTP Problem Types registry.
ProblemDetails in Detail
After learning where the concept comes from, let’s discover more about what ProblemDetails really is and what it consists of.
An RFC 9457 TLDR
So let’s shortly summarize the RFC 9457 defined Problem Details object. It is a JSON or XML format, which we can use for error responses (we are going to cover the JSON format here, and our article on content negotiation shows how a client asks for a format).
This object helps us inform the API client about details of errors in a machine-readable format, without us having to define what that looks like.
Let’s see an example of a response body formatted as the ProblemDetails object. Imagine you are trying to view a certain pizza in some pizza shop, but they don’t make it:
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Pizza.NotFound",
"status": 404,
"detail": "We don't make Hawaiian pizza.",
"traceId": "00-13ea9b9fad91dd88b091f167320a9533-63ac9ec122b1f6eb-00"
}
As we can see from the response headers, the Problem Details JSON object is of type application/problem+json. As the documentation specifies, it contains the following members:
type(string): URI reference to identify the problem typetitle(string): a short human-readable problem summarystatus(number): the HTTP status code generated on the problem occurrencedetail(string): a human-readable explanation for what exactly happenedinstance(string): URI reference of the occurrence
Some of these members deserve a few more notes, so let’s cover that in the next section.
More About ProblemDetails Object Members
The type member is assumed to be about:blank when left unspecified, which means the problem is exactly what the status code says. When it exists, it should provide a human-readable reference that describes the type of problem in general.
Here, in the example response, it links to the description of the HTTP status code in RFC 9110, which ASP.NET Core adds when we don’t set a type. In other cases, it can, for example, lead to a custom error page. That page would explain to the user that the pizza they were looking for doesn’t exist.
The title member shouldn’t change from one occurrence of the problem to the next, so our error code, Pizza.NotFound, fits there. But the RFC expects clients to tell problems apart by type, so a public API should give each error code its own type URI as well.
The status member is only advisory. It could be useful in some cases where we want to confirm that our response header hasn’t been tampered with. It should always correspond with the server-generated status code specified in the response headers. This is to make sure that the client behaves correctly even if they don’t understand the ProblemDetails format.
The instance member identifies the specific occurrence of the problem and it may or may not reference more detailed information. ASP.NET Core leaves it out unless we set it.
Besides the specified default format of the Problem Details object, we can also always extend it if we want to add custom information. We’ll see more about this later in our code examples.
The traceId in our example response is already such an extension, which ties the error to the logs of that request. Clients ignore extension members they don’t recognize, so our own ones break nobody.
The Problem Details object is not intended as a debugging tool, either. RFC 9457 puts it plainly: “Problem details are not a debugging tool for the underlying implementation.” We should always be careful about exposing our implementation details in these responses.
Using the ProblemDetails Class in Our APIs
In ASP.NET Core, since version 2.1, the Problem Details object is represented by the ProblemDetails class. In our ASP.NET Core Web APIs, there are a few places where the framework now automatically uses the ProblemDetails class: in returning error status codes and in model validations.
We can explore these examples more using our example project, a small pizza shop API. It keeps its stock in memory so we can focus on the ProblemDetails class.
Its minimal API endpoints are the entry point for the API, and we can send the requests and inspect the results with the PizzaShop.Api.http file, which we already have in the project.
The kitchen reports expected errors as values through a small Result type, which our Result pattern in .NET Web API article covers in detail.
Let’s start with the Program class, which registers two services:
using PizzaShop.Api; var builder = WebApplication.CreateBuilder(args); builder.Services.AddProblemDetails(); builder.Services.AddValidation(); var app = builder.Build(); app.MapPizzaEndpoints(); app.Run();
AddProblemDetails() registers the problem details service, which writes these bodies and adds the traceId. AddValidation() enables the attribute-based validation that minimal APIs gained in .NET 10.
Let’s run the app on port 5000, which our .http file uses:
dotnet run --project PizzaShop.Api -- --urls http://localhost:5000
The ProblemDetails Class and HTTP Status Codes
Since ASP.NET Core version 2.2, using the ControllerBase built-in methods for returning the HTTP status code responses, like NotFound() or BadRequest(), automatically formats the response as the ProblemDetails class. This is done thanks to the [ApiController] attribute in our controllers.
In minimal APIs, TypedResults.Problem() does the same job. Our project calls it in one place, the extension method that turns a failed result into a response:
namespace PizzaShop.Api;
public static class ResultExtensions
{
public static IResult ToProblem(this Result result)
{
var error = result.Error
?? throw new InvalidOperationException("A successful result is not a problem.");
var statusCode = error.Type switch
{
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Conflict => StatusCodes.Status409Conflict,
_ => StatusCodes.Status500InternalServerError
};
return TypedResults.Problem(statusCode: statusCode, title: error.Code, detail: error.Description);
}
}
ToProblem() maps each error type to a status code, 404 or 409, and puts the error code in title and the description in detail.
No other file in the project knows that a sold-out pizza is a 409. The kitchen says “conflict”, and only this edge turns that into HTTP.
The endpoints call it whenever the kitchen says no:
using System.ComponentModel.DataAnnotations;
namespace PizzaShop.Api;
public sealed record OrderRequest([property: Range(1, 20)] int Quantity);
public static class PizzaEndpoints
{
public static void MapPizzaEndpoints(this IEndpointRouteBuilder app)
{
var pizzas = app.MapGroup("/api/pizzas");
pizzas.MapGet("/{name}", (string name) =>
{
var result = Kitchen.Check(name);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
});
pizzas.MapPost("/{name}/orders", (string name, OrderRequest request) =>
{
var result = Kitchen.Order(name, request.Quantity);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
});
}
}
We can see this formatted error response in our example if we try to send a GET request for a pizza that doesn’t exist. That’s the Hawaiian 404 from the beginning. And if we order three Diavolas when the kitchen has two left, we get a 409:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "Pizza.SoldOut",
"status": 409,
"detail": "Only 2 Diavola left, 3 requested.",
"traceId": "00-42937105118b62de1ab7ac373c857aff-2e0f200644e85a7a-00"
}
What about a request that doesn’t reach any endpoint? The last request in our .http file has a typo on purpose, /api/pizza/ instead of /api/pizzas/. The app answers 404 with an empty body, and a client that expects problem details gets nothing to read.
The status code pages middleware fixes that. Let’s add it to our Program class, right after builder.Build():
app.UseStatusCodePages();
It adds a body to any error response that doesn’t have one yet. With AddProblemDetails() registered, that body is problem details:
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"traceId": "00-ad55064fb0f6f51d756ea67092305a10-260b04c2ec17a27f-00"
}
How Model Validations Use the ProblemDetails Class
Model validations automatically return a proper response to the client. The triggers for those validations are attributes in the model definition such as [Required] and [Range].
In our project, OrderRequest has a [Range(1, 20)] attribute on its Quantity, and AddValidation() checks it before our endpoint runs.
When we trigger these validations, our API returns responses as the HttpValidationProblemDetails class, which inherits from the ProblemDetails class and adds to it the Errors property. Controllers use ValidationProblemDetails, which derives from it, and both produce the same JSON.
We can use our example project to send a POST request with some wrong data, so we can inspect this behavior. If we order zero Margheritas, we are going to get an error response triggered by the model validations:
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Quantity": [
"The field Quantity must be between 1 and 20."
]
},
"traceId": "00-d306c2ff629269193891a2eceb3a5373-95d3d4d34b7a29f7-00"
}
For checks of your own, TypedResults.ValidationProblem() takes the same dictionary of field names and messages. Our FluentValidation article fills that dictionary from a validator’s errors.
In a controller, add your own errors to ModelState and return the controller’s ValidationProblem(), not Results.ValidationProblem(). When AddControllers() comes before AddProblemDetails() in Program.cs, MVC writes the minimal API result through its own problem details writer, and the 400 goes out without its errors map.
Exception Handling With the ProblemDetails Class
For the exception-handling part, the framework used to leave us on our own, and we had to do some heavy lifting ourselves. Usually, when an unhandled exception happens, our API returns the 500 Internal server error response.
To standardize this with the mentioned format and make life easier for ourselves and our API clients, we can try to:
- Define a new class that inherits from the
ProblemDetailsclass and use it in the try-catch blocks across the application - Use a built-in middleware
UseExceptionHandlerand configure its options to use theProblemDetailsclass to format responses - Create a custom middleware for global exception handling and configure it to map exceptions to the
ProblemDetailsclass - Use the ProblemDetails middleware created by Kristian Hellang
The goal is the same in all cases (except the first one): to keep our endpoints nice and clean. We do this by extracting the error handling into the pipeline. We thoroughly describe the general approach to handling errors globally in our guide on global error handling, so here we are going to focus only on the second option.
When we first wrote this article, ASP.NET Core didn’t give us an automated process for exception handling that returns a problem details object, so the Hellang middleware gave us a little advantage over the other options. Today, the built-in exception handler does exactly that.
It includes exception handling already set up out-of-the-box. The heavy lifting is done for us and it’s also formatted to the nice problem details standard. We are now going to see how to implement it into our APIs.
What About the Hellang ProblemDetails Middleware?
For a new app, we don’t need it anymore. Kristian Hellang’s middleware, first released in 2018, turned exceptions into problem details four years before the framework could, but its last release on NuGet, 6.5.1, came out in August 2022.
If your app still uses it, here’s where each piece went:
- Its
AddProblemDetails()andUseProblemDetails()become the framework’sAddProblemDetails(),UseExceptionHandler()andUseStatusCodePages() - Its
IncludeExceptionDetailsbecomes a Development-only check inCustomizeProblemDetails - Its
Map<TException>()becomes anIExceptionHandlerclass, which our global exception handling article builds
Does an app that still references it keep working on .NET 10? Not always. Its AddProblemDetails() without options now clashes with the framework’s method of the same name, so that call stops compiling:
error CS0121: The call is ambiguous between the following methods or properties: 'Microsoft.Extensions.DependencyInjection.ProblemDetailsServiceCollectionExtensions.AddProblemDetails(Microsoft.Extensions.DependencyInjection.IServiceCollection)' and 'Hellang.Middleware.ProblemDetails.ProblemDetailsExtensions.AddProblemDetails(Microsoft.Extensions.DependencyInjection.IServiceCollection)'
Calling it by its full name gets past the compiler, but a minimal API app then fails at startup, because the middleware needs services that come with MVC. So when you touch that code anyway, move to the built-in pieces and drop the package.
Setting Things Up
To set things up, we don’t need a NuGet package anymore. The problem details service is already registered, so we only have to add the exception handler to our Program class:
using PizzaShop.Api; var builder = WebApplication.CreateBuilder(args); builder.Services.AddProblemDetails(); builder.Services.AddValidation(); var app = builder.Build(); app.UseExceptionHandler(); app.UseStatusCodePages(); app.MapPizzaEndpoints(); app.Run();
We register the service with AddProblemDetails(), and we also add the middleware to our request processing pipeline by calling UseExceptionHandler(). It goes first, so it catches the exceptions from everything that comes after it.
The Middleware in Action
After we configure the necessary settings in our Program class, we can force an exception in a temporary endpoint, right after app.MapPizzaEndpoints():
app.MapGet("/api/oven", () =>
{
throw new InvalidOperationException("The oven is on fire.");
});
Once we send a GET request to this endpoint, our middleware is going to generate a ProblemDetails-formatted response:
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-7abad15dfb068fc59ecd3385d660924b-463f5560760cad74-00"
}
There is no sensitive data in our response here, in the development environment or in production. The exception itself goes to the log.
That’s a safe default, but while we develop, we’d often like to see what went wrong right in the response. We will use some additional customization to allow that in development only.
Additional Customization
Besides what the framework does for us, we have some space to customize things as well. Let’s see some useful and interesting things we can do.
Configuring Exception Details
Firstly, let’s customize the problem details service to include the exception details only in the development environment:
using PizzaShop.Api;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Instance = context.HttpContext.Request.Path;
if (context.Exception is not null && builder.Environment.IsDevelopment())
context.ProblemDetails.Extensions["exception"] = context.Exception.Message;
};
});
builder.Services.AddValidation();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapPizzaEndpoints();
app.Run();
We configure this in our Program class. The CustomizeProblemDetails callback runs for every problem details body the service writes: our ToProblem() answers, the validation errors, the status code pages and the exception handler’s 500s.
Here we set every problem’s instance to the request path, which tells a developer where to look, and add the exception message in the development environment only.
With our temporary endpoint still in place, the same GET request in development now carries the message:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"instance": "/api/oven",
"traceId": "00-09a7f2984fb3c8229bec836e8dcba794-3f943ffecd169c77-00",
"exception": "The oven is on fire."
}
Let’s now change to the production environment by adding --environment Production at the end of our dotnet run command. If we send the same GET request again, our response body doesn’t include the exception details anymore:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"instance": "/api/oven",
"traceId": "00-95fb8e51138e2c2e8a3beac688f7956f-1fddb9f55eb176bd-00"
}
If you want to learn more about managing environments in ASP.NET Core, you can do it by reading our article about using multiple environments in ASP.NET Core projects.
Extending the ProblemDetails Class
If we want to add our own information to the problem details object, we can also do that. Let’s say the sold-out answer should tell the client how many Diavolas are left, so it can offer those instead:
using Microsoft.AspNetCore.Mvc;
namespace PizzaShop.Api;
public sealed class SoldOutProblemDetails : ProblemDetails
{
public int Left { get; set; }
}
First, we extend the ProblemDetails class to fit our needs, with a Left property for the pizzas the kitchen still has. Then we return it from another temporary endpoint, to demonstrate the response:
app.MapGet("/api/sold-out", () =>
TypedResults.Problem(new SoldOutProblemDetails
{
Status = StatusCodes.Status409Conflict,
Title = "Pizza.SoldOut",
Detail = "Only 2 Diavola left, 3 requested.",
Left = 2
}));
Finally, we can inspect our custom response body:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "Pizza.SoldOut",
"status": 409,
"detail": "Only 2 Diavola left, 3 requested.",
"instance": "/api/sold-out",
"left": 2,
"traceId": "00-81252b7587793573ca23a91bbf78383a-e5ade1354768b2e7-00"
}
Excellent.
Everything works like a charm. The left member sits at the top level, next to the standard ones, and our instance from the previous section is there too.
For one extra member, we don’t even need a new class. TypedResults.Problem() also takes an extensions dictionary, and it gave us the same JSON:
TypedResults.Problem(
statusCode: StatusCodes.Status409Conflict,
title: "Pizza.SoldOut",
detail: "Only 2 Diavola left, 3 requested.",
extensions: new Dictionary<string, object?> { ["left"] = 2 });
Either way, name the member the way the rest of your JSON is named, and document it. The RFC treats each extension as part of that problem type’s definition.
Conclusion
We’ve learned about the importance of error and exception handling standardization in our web APIs. We met the ProblemDetails class and RFC 9457, and we saw how ASP.NET Core uses the class for status codes, model validations and exceptions without any extra package.
Now, it’s maybe time to revisit some API projects and do a bit of version updating and fresh refactoring :).
When you need a different response for each kind of exception, our article on global exception handling in ASP.NET Core picks up from here.
Happy coding!
Tested with .NET 10.0.12, Microsoft.AspNetCore.Mvc.Testing 10.0.12 and xUnit v3 4.0.1.

There is potentially one problem with this approach though. If exceptions are thrown lower layer (for example Service in a controller-service-repository architecture), there is no way the service can get the current Request path in order to inject into Instance of Problem Details.
Just as a note, there is no need extending ProblemDetails because it already has an extension mechanism, the property “Extensions” of type “Dictionary<string,object>” which can be used like this to store an “AdditionalInfo” property like the ProductCustomDetails in your example does:
services.AddProblemDetails(setup =>
{
setup.IncludeExceptionDetails = (ctx, env) => CurrentEnvironment.IsDevelopment() || CurrentEnvironment.IsStaging();
setup.Map<ProductCustomException>(exception => new ProblemDetails
{
Title = exception.Title,
Detail = exception.Detail,
Status = StatusCodes.Status500InternalServerError,
Type = exception.Type,
Instance = exception.Instance,
Extensions[“additionalInfo”] = exception.AdditionalInfo // this already creates a property and can be reused as much as necessary
});
});
Reference for .Net Core 5 (same goes for .Net Core 6):
https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.problemdetails?view=aspnetcore-5.0
Great article, don’t forget you can do
return Problem(title: "An error occurred while processing your request", statusCode: 400);in a controller too.
Thanks a lot for the comment. All the information are more than welcome 🙂
Hey Darran, glad to see you in the comments section!
Thanks for the help!
I used “Problem(…)” on controllers derived from ControllerBase or Controller, but you can’t use it with classes derived from ApiController.
Why is that?
The reason is that ApiController doesn’t implement “Problem(…)” method and is not derived from any other class that implements it:
https://docs.microsoft.com/en-us/dotnet/api/system.web.http.apicontroller?view=aspnetcore-2.2
Thanks, but it should be ok to use
ControllerBase<span style="color: rgb(119, 119, 119);">?https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.controllerbase?view=aspnetcore-2.2
Well.. it should, but it really depends on the ASP.NET Core Version you’re using (for example, in your link for ControllerBase with ASP.NET Core version 2.2 the method Problem(…) doesn’t exist).
In version 3.1 or greater, the method Problem(…) exists for type ControllerBase:
https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.controllerbase?view=aspnetcore-3.1
yeah, my bad.
And also.. ODataController (used when you implement OData), also derives from ApiController.
Hello, nice article.
However, I am not sure a title “Custom Product Exception” is really wise.
In a real-world project, it is probably not, but for the educational purposes, I am pretty sure it is quite ok.
I agree with @Nicolas, and if it’s not suitable for production I think you should consider to mention it in the article, other than that I really like the article.
Nice one! Thanks.