How to Implement Global Exception Handling in ASP.NET Core
Problem Details
For a long time, there wasn't a clear standard for presenting errors to clients from an HTTP-based API. HTTP itself offers many status codes, which is a good starting point. However, the occasional REST enthusiast claiming this is enough probably doesn't believe it themselves when asked privately.
In recent years, the problem details RFC has been widely adopted in web APIs. This is a very minimalist RFC that suggests including a simple JSON document with an HTTP error code. Here is a short example document from the RFC.
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en
{
"type": "https://example.com/probs/out-of-credit",
"title": "You do not have enough credit.",
"detail": "Your current balance is 30, but that costs 50.",
"instance": "/account/12345/msgs/abc",
"balance": 30,
"accounts": ["/account/12345",
"/account/67890"]
}
The fields type, title, detail, and instance are the minimum required to return. If you want to include specific data (like balance and accounts in this example), you can simply add them to the document.
I think the simplicity of the RFC is why it is widely adopted—there was no intention to strictly type errors like SOAP does with fault contracts. When using OpenAPI, you can easily integrate problem details into your specification and even add specific extensions if you wish.
Problem Details in ASP.NET Core
Since the RFC is newer than ASP.NET Core itself, Microsoft did not provide a standard implementation initially. In most projects, I saw either custom middleware handling this or Hellang.Middleware.ProblemDetails, which works very well.
A few years ago, Microsoft did provide a standard implementation, which I completely forgot about after initially thinking, "Cool - I’m going to use that from now on!" :P
Recently, Milan Jovanović posted a great video about the API, which reminded me of it. I decided to blog about it so I don’t forget again.
The implementation consists of two parts, as is common with many ASP.NET Core extensions:
DI service registrations
Pipeline extensions
Here is the minimal registration code:
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<SomeExceptionHandler>();
builder.Services.AddExceptionHandler<SomeOtherExceptionHandler>();
app.UseExceptionHandler();
As you can see, you can have multiple exception handlers. These implement IExceptionHandler. The order of registration is important—handlers will run in the order they are registered. A handler can specify whether it can handle a specific exception and then write the problem details. If it’s not responsible, control is passed to the next handler.
Here is a simple implementation of a fallback handler, which you might include at the end of the handler chain:
public partial class FallbackExceptionHandler : IExceptionHandler
{
private readonly ILogger<FallbackExceptionHandler> _logger;
private readonly IProblemDetailsWriter _problemDetailsWriter;
public FallbackExceptionHandler(
ILogger<FallbackExceptionHandler> logger,
IProblemDetailsWriter problemDetailsWriter)
{
_logger = logger;
_problemDetailsWriter = problemDetailsWriter;
}
[LoggerMessage(LogLevel.Error, "An unexpected error occured: {Instance}")]
private static partial void LogApplicationFailure(ILogger<FallbackExceptionHandler> logger, Exception exception, Guid instance);
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
Guid instance = Guid.NewGuid();
LogApplicationFailure(_logger, exception, instance);
httpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest;
await _problemDetailsWriter.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Status = (int)HttpStatusCode.BadRequest,
Instance = instance.ToString(),
#if DEBUG
Title = exception.Message,
Type = $"urn://{exception.GetType().FullName}"
#else
Title = "Unexpected error"
#endif
}
});
return true;
}
}
Here are some important points:
Use
IProblemDetailsWriterto create the JSON for the problem details.Choose the HTTP status code that best fits your error (e.g.,
409 Conflictfor optimistic locking).Instanceshould refer to something that can be found in the logs.Titleis a clear text description of the error, andTypedefines the error type as a URI. This should be used if clients need to recognize the error type.Avoid exposing security-related information outside of a development system. In this case, I included an
#ifstatement to ensure that only a generic error is shown when not in debug mode.
The Antipattern
As a side note, a year ago, I saw a custom exception handler manually implemented as middleware, which included code like this:
if (excepction is MyCustomException1)
{
// Write problem details mapping MyCustomException1
}
else if (excepction is MyCustomException2)
{
// Write problem details mapping MyCustomException2
}
else
{
// Write generic problem details mapping for other exceptions
}
Code like this has issues because it puts all the exception mapping logic in one method, which goes against the open-closed principle. Adding new exceptions often requires changes to this method, creating a strong link between this middleware and the business logic code you write.
Microsoft's implementation makes it easy to avoid this by allowing you to have separate handlers for different types of exceptions.
What kind of exceptions should we handle?
I would suggest that you don't need to focus too much on what is sent to a client from an exception, as it's an unusual situation. If you need to send specific typed data, you might want to model different types of possible results. Here's an example of what that could look like:
Success
{
"status": "ok",
}
Validation Error
{
"status": "validation-failed",
"validation-errors": [
{
"title": [
"Title is a required field."
]
}
]
}
In this case, I suggest having a specific type of result for validation errors because you have specific data, like the field name title, that the client might rely on.
However, this is open to debate, and you might decide that it's perfectly fine to have the validation error as a problem details response.
A useful way to handle different types of exceptions across projects is to consider whether the exception should be shown to the user. Generally, there are two types of exceptions:
System exceptions
These are technical issues that the user cannot control and likely won't understand. Examples include:NullReferenceException
ObjectNotSetToAnInstance
DivisionByZero
…
User exceptions
These are more meaningful to the user, allowing them to take action. Examples include:Validation Errors
Business Rule Violations
Permission Errors
…
A simple way to differentiate between these two types of exceptions is to introduce a base class (e.g., UserVisibleException). With this setup, you could have at least two different exception handlers, one for each type. The system exceptions handler would mostly return an ID that can be found in the logs, while the user exception handler can provide a detailed result with actionable information.
The Demo Solution
I created a small demo solution that does exactly this. It has two handlers: one for user-visible exceptions and another as a generic fallback.
In the registration section, you can see that the fallback handler is registered last:
builder.Services.AddExceptionHandler<BusinessLogicExceptionHandler>();
builder.Services.AddExceptionHandler<FallbackExceptionHandler>();
BusinessLogicExceptionHandlerhandles exceptions that come fromBusinessLogicException(these are the user-visible exceptions). It provides detailed output.FallbackExceptionHandlermanages all other exceptions. It provides basic output.