1021 words
5 minutes
Global Error Handling in ASP.NET Core Web API

Introduction#

In ASP.NET Core projects, exception handling is an essential part of ensuring the application runs smoothly and responds well when issues arise. When it comes to exception handling, most people immediately think of try-catch blocks in each controller action. Using try-catch blocks in individual actions is the traditional approach that we can easily find in projects. However, is this approach truly effective? Let’s explore this together in this article. Let’s go!

Exception Handling with Try-Catch Blocks#

Below is an example demonstrating the use of a Try-Catch block for exception handling. This code is for illustration purposes. Here I use Mediator and CQRS to handle queries and commands. But you only need to focus on the main purpose, which is demonstrating exception handling with Try-Catch so we can do some analysis afterwards. (Note: In this article I want to focus on Exceptions. In a separate article, I will share more about Errors.)

[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
    private ILoggerManager _logger;
    private readonly IScopedMediator _mediator;
    public ValuesController(ILoggerManager logger, IScopedMediator mediator)
    {
        _logger = logger;
    }

    [HttpGet]
    public IActionResult Get()
    {
        try
        {
            _logger.LogInfo("Fetching all the Products from ElasticSearch");
            var products = await _mediator.SendRequest(new GetProducts(
                request.SearchString,
                request.Ids,
                request.SortOrder,
                request.SortDirection,
                request.Skip,
                request.Take));
            _logger.LogInfo($"Returning {students.Count} students.");
            return Ok(products);
        }
        catch (Exception ex)
        {
            _logger.LogError($"Something went wrong: {ex}");
            return StatusCode(500, "Internal server error");
        }
    }
}

As you can see, this traditional Try-Catch block approach has several drawbacks that are easy to identify:

  1. Controllers become very long (many lines) if the number of actions (endpoints) is large.
  2. Code can be repetitive if we return the same error codes.
  3. We end up using Ctrl+C and Ctrl+V extensively because we typically copy-paste when creating new endpoints. So is there a way to overcome these drawbacks? The answer is yes — Global Exception Handling is the solution. Keep reading to see how Global Exception Handling addresses the drawbacks of the traditional approach!

Global Exception Handling with Middleware#

Based on the drawbacks mentioned above with the traditional approach of using Try-Catch blocks in all endpoints, I raise some questions and analysis as follows:

So the solution is clear. It’s time to practice writing a Middleware for exception handling. In my experience, there are two commonly used approaches for Global Exception Handling: using Middleware or using the IExceptionHandler interface from .NET 8.

Using Built-in Middleware#

With the Middleware approach, we can use either Built-in Middleware or Custom Middleware. First, with Built-in Middleware, we use the app.UseExceptionHandler built-in method. In the code below, to ensure SOLID principles, I created a static class called ExceptionMiddlewareExtensions with an Extension Method called ConfigureExceptionHandler for use in the next step. If you are not familiar with Middleware (built-in middleware and custom middleware) or Extension Methods, I will have a separate article about them. It will likely be part of the ”.NET Foundation” series — follow along to read more!

public static class ExceptionMiddlewareExtensions
{
    public static void ConfigureExceptionHandler(this WebApplication app)
    {
        app.UseExceptionHandler(appError =>
        {
            appError.Run(async context =>
            {
                context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
                context.Response.ContentType = "application/json";
                var contextFeature = context.Features.Get<IExceptionHandlerFeature>();
                if (contextFeature != null)
                {
                    app.Logger.LogError($"Something went wrong: {contextFeature.Error}");
                    await context.Response.WriteAsync(text: JsonConvert.SerializeObject(new ErrorDetails()
                    {
                        StatusCode = context?.Response?.StatusCode ?? 500,
                        Message = contextFeature.Error.Message, //"Internal Server Error."
                        StackTrace = contextFeature.Error.StackTrace ?? ""
                    }));
                }
            });
        });
    }
}

Use it in the Config method in the Startup.cs file:

app.ConfigureExceptionHandler(logger);

Using Custom Middleware#

With the custom middleware approach, the steps are quite similar to using built-in middleware. The difference is that instead of using app.UseExceptionHandler, we create an ExceptionMiddleware class. The details of the methods in this class will make sense after you read the article about Middleware — in this article I will not go into detailed explanations of each method.

public class ExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILoggerManager _logger;

    public ExceptionMiddleware(RequestDelegate next, ILoggerManager logger)
    {
        _logger = logger;
        _next = next;
    }

    public async Task InvokeAsync(HttpContext httpContext)
    {
        try
        {
            await _next(httpContext);
        }
        catch (Exception ex)
        {
            _logger.LogError($"Something went wrong: {ex}");
            await HandleExceptionAsync(httpContext, ex);
        }
    }

    private async Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        context.Response.ContentType = "application/json";
        context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;

        await context.Response.WriteAsync(new ErrorDetails()
        {
            StatusCode = context.Response.StatusCode,
            Message = "Internal Server Error from the custom middleware."
        }.ToString());
    }
}

Similar to the built-in middleware approach, I create an extension method to ensure the code follows SOLID principles.

public static void ConfigureCustomExceptionMiddleware(this IApplicationBuilder app)
{
    app.UseMiddleware<ExceptionMiddleware>();
}

Finally, just use it in Startup.cs.

app.ConfigureCustomExceptionMiddleware();

Global Exception Handling with IExceptionHandler Interface from .NET 8#

Using Middleware for Global Exception Handling is a good solution. However, .NET 8 introduces IExceptionHandler — a dedicated approach for Global Exception Handling. Create a CustomExceptionHandler class and implement the IExceptionHandler interface.

public class CustomExceptionHandler
    (ILogger<CustomExceptionHandler> logger)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken)
    {
        logger.LogError(
            "Error Message: {exceptionMessage}, Time of occurrence {time}",
            exception.Message, DateTime.UtcNow);

        (string Detail, string Title, int StatusCode) details = exception switch
        {
            InternalServerException =>
            (
                exception.Message,
                exception.GetType().Name,
                context.Response.StatusCode = StatusCodes.Status500InternalServerError
            ),
            ValidationException =>
            (
                exception.Message,
                exception.GetType().Name,
                context.Response.StatusCode = StatusCodes.Status400BadRequest
            ),
            BadRequestException =>
            (
                exception.Message,
                exception.GetType().Name,
                context.Response.StatusCode = StatusCodes.Status400BadRequest
            ),
            NotFoundException =>
            (
                exception.Message,
                exception.GetType().Name,
                context.Response.StatusCode = StatusCodes.Status404NotFound
            ),
            _ =>
            (
                exception.Message,
                exception.GetType().Name,
                context.Response.StatusCode = StatusCodes.Status500InternalServerError
            )
        };

        var problemDetails = new ProblemDetails
        {
            Title = details.Title,
            Detail = details.Detail,
            Status = details.StatusCode,
            Instance = context.Request.Path
        };

        problemDetails.Extensions.Add("traceId", context.TraceIdentifier);

        if (exception is ValidationException validationException)
        {
            problemDetails.Extensions.Add("ValidationErrors", validationException.Errors);
        }

        await context.Response.WriteAsJsonAsync(problemDetails, cancellationToken: cancellationToken);
        return true;
    }
}

Use it in Program.cs:

builder.Services.AddExceptionHandler<CustomExceptionHandler>();

Conclusion#

In this article, I have introduced several approaches to exception handling in .NET systems. I hope this article will be helpful for newbie and junior developers, giving you some guidance to apply in your real-world projects. Thank you, and see you guys in the next post!

Global Error Handling in ASP.NET Core Web API
https://www.devwithshawn.com/en/posts/dotnet-global-exception-handling-en/
Author
PDXuan(Shawn)
Published at
2024-05-01
Share: