This article will focus on handling custom exceptions for flow control on .NET with a global exception handler and return a problem details object.
Having said that. There are, for the most part, two schools of thought. One where it is OK to throw a (custom) exception to control flow. i.e. Throw a
when a user is not found. The second is implementing the Result Pattern where you return a Success or an Error to control the flow. i.e. UserNotFoundException
.Result.UserNotFound
Problem Details
Problem details is the standard way of returning error messages from a REST API to the client.
Say your API is validating for a required field. i.e.
. If the name is not present in the API request, instead of only returning a 400 Bad Request HTTP Status Code, you return a problem details response detailing what went wrong with the request so clients can take action on the invalid request.Name
The members of the problem details response are as follows:
- type – identifies the problem details type
- title – summary of the problem details
- status – HTTP Status code of the issue
- errors – details of what went wrong with the request
{ "type": "https://httpstatuses.com/400", "title": "One or more validation errors occurred.", "status": 400, "errors": { "Name": [ "'Name' must not be empty." ] } }
Global Exception Handler
You add the global exception handler to
like soProgram.cs
var app = builder.Build(); app.UseExceptionHandler(GlobalExceptionHandler.Configure);
Then the implementation of the
static classGlobalExceptionHandler
public static class GlobalExceptionHandler { public static void Configure(IApplicationBuilder builder) { builder.Run(async context => { var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>(); // Declare the problem results IResult problemResult; // Switch statement to match the custom exceptions switch (exceptionHandlerPathFeature?.Error) { case UserAlreadyExistsException: { var details = new ProblemDetails { Type = "https://httpstatuses.com/409", Title = "User already exists.", Status = StatusCodes.Status409Conflict, }; problemResult = Results.Problem(details); break; } // Other custom exceptions, say UnauthorizedException and return // a 401 Unauthorized problem details // This custom exception here contains validation errors from // Fluent Validation case ApiValidationException: { // Casting the exception to ApiValidationException to get the // `Errors` property and send it back to the client var exp = (ApiValidationException)exceptionHandlerPathFeature!.Error; problemResult = Results.ValidationProblem ( exp.Errors, type: "https://httpstatuses.com/400", statusCode: StatusCodes.Status400BadRequest ); break; } // If no custom exception is matched, return generic 500 Internal Server // error response default: { var details = new ProblemDetails { Type = "https://httpstatuses.com/500", Title = "An error occurred while processing your request.", Status = StatusCodes.Status500InternalServerError }; problemResult = Results.Problem(details); break; } } await problemResult.ExecuteAsync(context); }); } }
Bonus! Minimal APIs and Fluent Validation
With Minimal APIs, you have to invoke Fluent Validation inside the endpoint like so
var validationResult = await validator.ValidateAsync(userSignup, cancellationToken); if (validationResult.IsValid is false) throw new ApiValidationException(validationResult.ToDictionary());
In the example above, I map the validation results to
custom exception. The global exception handler will handle this exception returning the appropriate error response with a 400 Bad Request status codeApiValidationException
And the
implementation detailsApiValidationException
public class ApiValidationException : Exception { public IDictionary<string,string[]> Errors { get; } public ApiValidationException(IDictionary<string,string[]> errors) : base() { Errors = errors; } }
.NET 8
There is a new way of handling global exceptions in .NET 8. I will write about it at some point. Having said that, the code shown here will also work with .NET 8.