Home > Blockchain >  ASPNET core - Where to catch errors in a web API
ASPNET core - Where to catch errors in a web API

Time:12-30

Running the risk of asking an opinion-based question, I would like to know which approach you use to handle exceptions or unexpected results in your ASP.NET core web API.

I have seen two approaches (maybe there is another, better, one):

Result pattern

Exceptions are not handled by the controller, instead a service handles exceptions. The controller actions have a signature ActionResult<Result<T>> where T is a resource/data object and Result<T> is a wrapper with additional fields such as Errors and ResultType .

Example:

// BlogPostsController.cs
...
private readonly IBlogPostsService _service;

[HttpGet]
public async Task<ActionResult<Result<BlogPost>>> GetPostById(Guid postId) 
{
    var result = await _service.GetPostById(postId);
    return result.ResultType switch 
    {
         ResultType.NotFound   => NotFound(result),
         ResultType.Ok         => Ok(result),
         ResultType.Unexpected => BadRequest(result)
    }
}

Custom exceptions middleware

Exceptions are not handled by the controller, instead a service throws custom exceptions which are handled by a middleware.

Example:

// BlogPostsController.cs
...
private readonly IBlogPostsService _service;

[HttpGet]
public async Task<IActionResult> GetPostById(Guid postId) 
{
    return await _service.GetPostById(postId);
}

--------------------

// BlogPostsService.cs
public async Task<BlogPost?> GetPostById(Guid postId) 
{
    var post = await _db.Where(post => post.Id == postId).SingleOrDefaultAsync();
    if (post == null)
       throw new PostNotFoundException($"Post {postId} not found");
    return post;
}

----------------------
// ExceptionMiddleware.cs

public async Task Invoke(HttpContext context) 
{
    try
    {
        await _next(context);
    }
    catch (PostNotFoundException) 
    {
        var details = new ExceptionDetails()
        {
             Status = 404,
             Detail = "Post does not exist"
        };
        await context.Response.WriteAsync(JsonConvert.SerializeObject(details));
    }
    ...
}

CodePudding user response:

In both examples you mention:

Exceptions are not handled by the controller

There are two types of exception:

  1. Expected: e.g. a broken rule is identified in your service and an application-defined exception is thrown that the controller wishes to handle.
  2. Unexpected: e.g. the service throws a NullReferenceException because of a bug in code. Or a broken rule application-defined exception is thrown but not caught by the controller.

I see no problem with your controller catching expected exceptions and resolving them to a API-defined custom error DTO and returning that in a BadRequest (400):

Controller

[HttpPost]
public async Task<IActionResult> PostAsync(
   [FromBody] PostDto postDto)
{
    try
    {
        await _myService.DoStuff(postDto.Prop1, postDto.Prop2);

        return Ok();
    }
    catch (BrokenBizRuleException ex)
    {
        return BadRequest(new CustomExceptionDto("Failed to do stuff.", ex.Message));
    }
}

Then use your middleware to catch remaining unhandled exceptions and turn them into InternalServerError (500) with a CustomExceptionDto.

public async Task Invoke(HttpContext context) 
{
    try
    {
        await _next(context);
    }
    catch (Exception ex) 
    {
        var details = new CustomExceptionDto(
            "Unexpected Exception",
            ex.Message);

        context.Response.StatusCode = 500;
        await context.Response.WriteAsJsonAsync(details);
    }
}

Putting the handling of all business errors in your middleware goes against Single Responsibility Principle a bit too much for my liking.

Your controller is the one who knows which errors it wishes to handle and those which it wishes to leave to the middleware (i.e. unhandled).

Also, consider the semantics of 400 and 500:

  • 400 means the exception is due to a client error (e.g. unacceptable value for current business state)
  • 500 means the exception is due to the server making an error (bug).

You should try to ensure the Controller catches all business exceptions to indicate a client exception (400) and let the middleware resolve all others as server exceptions (500).

Either way, the above pattern will ensure that any unhandled exception, whether system or application, will be mapped to 500 response with error message as content.

Obviously, you can customise CustomExceptionDto as you wish. I tend to include an API-defined ErrorCode in mine. Then I can map Application Exceptions received from services called by the controller to an API-defined ErrorCode, which can be helpful for the UI responding in differing ways, depending on the specified error code.

  • Related