In this article, we are going to talk about the ProblemDetails class and how it plays a role in standardizing error and exception handling in our .NET Core APIs.

To download the source code for this article, you can visit our ProblemDetails Class repository.

We are going to divide this article into the following sections: 

Let’s get started!

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

IETF to the rescue! The IETF RFC 7807 document addresses this topic and creates a standardized format by specifying the Problem Details object. We will explore how that object looks like in a bit more detail and how and where we use it in our .NET Core Web APIs. We are also going to try out a few things from our example project.

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 7807 TLDR;

So let’s shortly summarize the RFC 7807 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). 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 product in some webshop, but it doesn’t exist anymore:

problemdetails-response-example

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 type
  • Title – [string] – a short human-readable problem summary
  • Status – [number] – the HTTP status code generated on the problem occurrence
  • Detail – [string] – a human-readable explanation for what exactly happened
  • Instance – [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. When it exists, it provides a human-readable reference that describes the type of problem in general. Here, in the example response, it leads to a custom error page. That page would explain to the user that the product they were looking for doesn’t exist. In other cases, it can, for example, link to the description of the HTTP status code.

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.

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.

It’s important to note that the Problem Details object is not intended as a debugging tool. We should always be careful about exposing our implementation details in these responses.

Using the ProblemDetails Class in Our APIs

In .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 starting project source code that you can find here. The example project uses an in-memory database so we can focus on the ProblemDetails class. It uses the ProductController as the primary entry point for the API and we can send the requests and inspect the results using swagger, which we already have configured in the project.

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 Ok() or BadRequest(), automatically formats the response as the ProblemDetails class. This is done thanks to the [ApiController] attribute in our controllers.
We can see this formatted error response in our example if we try to send a GET request with a product id that doesn’t exist:

problemdetails-json-notfound-error

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 [EmailAdress].
When we trigger these validations, our API returns responses as the ValidationProblemDetails class, which inherits from the ProblemDetails class and adds to it the Errors field.

We can use our example project to send a POST request with some wrong data, so we can inspect this behavior. If we send a request body without a product name, and with a category name longer than 20 characters, we are going to get an error response triggered by the model validations:

problemdetails-json-badrequest-error

Exception Handling With the ProblemDetails Class

For the exception-handling part, however, we are not covered by the framework and we have 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 exception class that inherits from the ProblemDetails class and use it in the try-catch blocks across the application
  • Use a built-in middleware UseExceptionHandler and configure its options to use the ProblemDetails class to format responses
  • Create a custom middleware for global exception handling and configure it to map exceptions to the ProblemDetails class
  • Use the ProblemDetails middleware created by Kristian Hellang

The goal is the same in all cases (except the first one) – to keep our controllers 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 last option.

Until ASP.NET Core gives us an automated process for exception handling that returns a problem details object, the Hellang ProblemDetails middleware gives us a little advantage over the other options. 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.

Setting Things Up

To set up the middleware, let’s first install the NuGet package, using the NuGet package manager or by running the Package Manager Console command:

Install-Package Hellang.Middleware.ProblemDetails

In our example project, we are using the middleware version 5.1.1. which was the latest version available. After installation, we first have to configure our Startup.cs file:

public void ConfigureServices(IServiceCollection services)
{
    services.AddProblemDetails();

    services.AddDbContext<AppDbContext>(options =>
    {
        options.UseInMemoryDatabase("test-db");
    });

    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "ErrorHandlingProblemDetails", Version = "v1" });
    });

    services.AddScoped<IProductService, ProductService>();

    services.AddControllers();
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseProblemDetails();

    app.UseSwagger();

    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "ErrorHandlingProblemDetails v1");
    });

    app.UseHttpsRedirection();

    app.UseRouting();

    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

We register the service in our ConfigureServices method with the AddProblemDetails(), and we also add the middleware to our request processing pipeline by calling UseProblemDetails().

The Middleware in Action

After we configure the necessary settings in our Startup class, we can force an exception in our controller action:

[HttpGet("byName/{name}")]
public async Task<ActionResult<Product>> GetByName(string name)
{
    throw new Exception("There was an exception while fetching the product");

    var product = await _productService.GetProductByName(name);
    if (product == null)
        return NotFound();

    return Ok(product);
}

Once we use Swagger to send the GET request to this endpoint, our middleware is going to generate a ProblemDetails-formatted response:

problemdetails json exception dev

There is a lot of sensitive data in our response here, and this should only happen in our development environment. We will use some additional customization to ensure this does not happen in production.

Additional Customization

Besides the implemented functionalities of the middleware, we have some space to customize things as well. Let’s see some useful and interesting things we can do.

Configuring Exception Details

Firstly, we should always customize the middleware to include the exception details only in the development environment:

public void ConfigureServices(IServiceCollection services)
{
    services.AddProblemDetails(setup =>
    {
        setup.IncludeExceptionDetails = (ctx, env) => CurrentEnvironment.IsDevelopment() || CurrentEnvironment.IsStaging();
    });

    services.AddDbContext<AppDbContext>(options =>
    {
        options.UseInMemoryDatabase("test-db");
    });

    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "ErrorHandlingProblemDetails", Version = "v1" });
    });

    services.AddScoped<IProductService, ProductService>();

    services.AddControllers();
}

We configure this in our Startup class. Here we set it up to use the exception details only in the development or staging environment – just to show the possibility.

Let’s now change to the production environment. If we send the same GET request again, our response body doesn’t include the exception details anymore:

middleware-500-error

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 create our custom exception class and map it to the problem details object created by the middleware, we can also do that:

public class ProductCustomException : Exception
{
    public string AdditionalInfo { get; set; }
    public string Type { get; set; }
    public string Detail { get; set; }
    public string Title { get; set; }
    public string Instance { get; set; }

    public ProductCustomException(string instance)
    {
        Type = "product-custom-exception";
        Detail = "There was an unexpected error while fetching the product.";
        Title = "Custom Product Exception";
        AdditionalInfo = "Maybe you can try again in a bit?";
        Instance = instance;
    }
}
public class ProductCustomDetails : ProblemDetails
{
    public string AdditionalInfo { get; set; }
}

First, we create a custom exception class, and then we also extend the ProblemDetails class to fit our needs. With our custom exception, we want to show the user some additional information about the error. For this, in our custom exception class, we add a custom AdditionalInfo property. We also extend the ProblemDetails class with the property of the same name, so we can map everything correctly.

After that, we are going to update the Startup.cs configuration to map our custom exception to the ProductCustomDetails class:

public void ConfigureServices(IServiceCollection services)
{
    services.AddProblemDetails(setup =>
    {
        setup.IncludeExceptionDetails = (ctx, env) => CurrentEnvironment.IsDevelopment() || CurrentEnvironment.IsStaging();

        setup.Map<ProductCustomException>(exception => new ProductCustomDetails
        {
            Title = exception.Title,
            Detail = exception.Detail,
            Status = StatusCodes.Status500InternalServerError,
            Type = exception.Type,
            Instance = exception.Instance,
            AdditionalInfo = exception.AdditionalInfo
        });
    });

    services.AddDbContext<AppDbContext>(options =>
    {
        options.UseInMemoryDatabase("test-db");
    });

    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "ErrorHandlingProblemDetails", Version = "v1" });
    });

    services.AddScoped<IProductService, ProductService>();

    services.AddControllers();
}

And force our custom exception in the controller to demonstrate the response:

[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetById(int id)
{
    throw new ProductCustomException(Request.Path.Value);

    var product = await _productService.GetProductById(id);
    if (product == null)
        return NotFound();

    return Ok(product);
}

Finally, we can inspect our custom exception response body (in the production environment):

middleware-custom-exception

Excellent.
Everything works like a charm.

Conclusion

We’ve learned about the importance of error and exception handling standardization in our web APIs. We met the ProblemDetails class and learned about a useful exception handling middleware which implements it.
Now, it’s maybe time to revisit some API projects and do a bit of version updating and fresh refactoring :).

Happy coding!