Comprehensive Guide to Error Handling in ASP.NET Core
Published: 2024-06-27 23:13:09

Proper error handling and propagation are critical in building robust and reliable applications. In a layered architecture, it's essential to manage errors effectively across all layers: Presentation, Application, Domain, and Infrastructure. This guide will walk you through the best practices for error handling in an ASP.NET Core Web API using a layered architecture.

### 1. General Principles

**Principle 1: Use Exception Handling for Unexpected Errors**
- Exceptions should represent unexpected errors, not control flow.
- Use custom exceptions for specific error conditions.

**Principle 2: Validate Inputs Early**
- Perform input validation as soon as possible, ideally in the Presentation layer.

**Principle 3: Log Errors**
- Log errors at the appropriate level to diagnose and troubleshoot issues.
- Use a centralized logging framework (e.g., Serilog, NLog).

**Principle 4: Propagate Errors Up the Stack**
- Propagate exceptions up to higher layers, but handle them gracefully.
- Transform exceptions to meaningful responses in the Presentation layer.

### 2. Layered Architecture Error Handling

#### Presentation Layer (API Layer)

**Responsibilities:**
- Handle HTTP requests and responses.
- Validate input data.
- Catch and translate exceptions into appropriate HTTP responses.

**Implementation:**

1. **Global Exception Handling Middleware:**

```csharp
public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;

    public ExceptionHandlingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext httpContext)
    {
        try
        {
            await _next(httpContext);
        }
        catch (Exception ex)
        {
            await HandleExceptionAsync(httpContext, ex);
        }
    }

    private Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        var code = HttpStatusCode.InternalServerError; // 500 if unexpected

        if (exception is NotFoundException) code = HttpStatusCode.NotFound;
        else if (exception is ValidationException) code = HttpStatusCode.BadRequest;

        var result = JsonSerializer.Serialize(new { error = exception.Message });
        context.Response.ContentType = "application/json";
        context.Response.StatusCode = (int)code;

        return context.Response.WriteAsync(result);
    }
}

// Register middleware in Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseMiddleware<ExceptionHandlingMiddleware>();
    app.UseRouting();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}
```

2. **Model Validation:**

```csharp
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IOrderService _orderService;

    public OrdersController(IOrderService orderService)
    {
        _orderService = orderService;
    }

    [HttpPost]
    public IActionResult PlaceOrder([FromBody] PlaceOrderRequest request)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }

        _orderService.PlaceOrder(request.CustomerId, request.Items);
        return Ok();
    }

    [HttpGet("{orderId}")]
    public IActionResult GetOrderById(Guid orderId)
    {
        var order = _orderService.GetOrderById(orderId);
        if (order == null)
        {
            return NotFound();
        }
        return Ok(order);
    }
}
```

#### Application Layer

**Responsibilities:**
- Orchestrate business logic.
- Translate domain errors into application-specific exceptions.

**Implementation:**

```csharp
public interface IOrderService
{
    void PlaceOrder(Guid customerId, List<OrderItemDto> items);
    OrderDto GetOrderById(Guid orderId);
}

public class OrderService : IOrderService
{
    private readonly IOrderRepository _orderRepository;
    private readonly IProductRepository _productRepository;

    public OrderService(IOrderRepository orderRepository, IProductRepository productRepository)
    {
        _orderRepository = orderRepository;
        _productRepository = productRepository;
    }

    public void PlaceOrder(Guid customerId, List<OrderItemDto> items)
    {
        try
        {
            var order = new Order();
            foreach (var item in items)
            {
                var product = _productRepository.GetById(item.ProductId);
                if (product == null)
                {
                    throw new NotFoundException("Product not found");
                }
                order.AddItem(product, item.Quantity);
            }
            _orderRepository.Save(order);
        }
        catch (DomainException ex)
        {
            throw new ApplicationException("An error occurred while placing the order", ex);
        }
    }

    public OrderDto GetOrderById(Guid orderId)
    {
        var order = _orderRepository.GetById(orderId);
        if (order == null)
        {
            throw new NotFoundException("Order not found");
        }
        return new OrderDto(order);
    }
}
```

#### Domain Layer

**Responsibilities:**
- Encapsulate business logic and rules.
- Validate state and throw domain-specific exceptions.

**Implementation:**

```csharp
public class Order
{
    public Guid Id { get; private set; }
    public DateTime OrderDate { get; private set; }
    public List<OrderItem> Items { get; private set; }

    public Order()
    {
        Id = Guid.NewGuid();
        OrderDate = DateTime.Now;
        Items = new List<OrderItem>();
    }

    public void AddItem(Product product, int quantity)
    {
        if (product == null) throw new DomainException("Product cannot be null");
        if (quantity <= 0) throw new DomainException("Quantity must be greater than zero");

        var orderItem = new OrderItem(product, quantity);
        Items.Add(orderItem);
    }

    public decimal GetTotalAmount()
    {
        return Items.Sum(item => item.TotalPrice);
    }
}

public class DomainException : Exception
{
    public DomainException(string message) : base(message) { }
}
```

#### Infrastructure Layer

**Responsibilities:**
- Handle data persistence and external systems.
- Convert infrastructure-specific errors into domain or application exceptions.

**Implementation:**

```csharp
public interface IOrderRepository
{
    void Save(Order order);
    Order GetById(Guid orderId);
}

public class OrderRepository : IOrderRepository
{
    private readonly ApplicationDbContext _context;

    public OrderRepository(ApplicationDbContext context)
    {
        _context = context;
    }

    public void Save(Order order)
    {
        try
        {
            _context.Orders.Add(order);
            _context.SaveChanges();
        }
        catch (DbUpdateException ex)
        {
            throw new InfrastructureException("An error occurred while saving the order", ex);
        }
    }

    public Order GetById(Guid orderId)
    {
        try
        {
            return _context.Orders.Include(o => o.Items).FirstOrDefault(o => o.Id == orderId);
        }
        catch (Exception ex)
        {
            throw new InfrastructureException("An error occurred while retrieving the order", ex);
        }
    }
}

public class InfrastructureException : Exception
{
    public InfrastructureException(string message, Exception innerException) : base(message, innerException) { }
}
```

### 3. Best Practices for Error Handling

1. **Centralized Exception Handling:**
   - Use middleware to handle exceptions globally, ensuring consistent error responses.

2. **Specific Exception Types:**
   - Define custom exception types for different error conditions to improve clarity and handling.

3. **Consistent Error Responses:**
   - Ensure that all errors are translated into meaningful and consistent HTTP responses.

4. **Logging:**
   - Log exceptions at appropriate levels (e.g., error, warning) with sufficient context to aid troubleshooting.

5. **Validation:**
   - Perform validation early and often, especially in the presentation and application layers.

### Summary

By applying these practices, you can ensure that errors are handled consistently and effectively across all layers of your application. This approach enhances the robustness, maintainability, and clarity of your software system. Proper error handling is not just about catching exceptions but also about providing meaningful feedback, logging appropriately, and ensuring that the system can gracefully recover or fail.

I'll provide a comprehensive explanation of the Result pattern for error handling in layered architectures, with a focus on ASP.NET Core Web API. Let's break this down into sections as you've outlined.

1. Introduction

Traditional error handling approaches like exceptions and error codes have several drawbacks in layered architectures:

- Exceptions can be expensive and can lead to unclear control flow.
- Error codes often require extensive if-else chains and can be easily ignored.
- Both approaches can make it difficult to propagate detailed error information across layers.

The Result pattern offers a functional approach to error handling that addresses these issues.

2. Benefits of the Result Pattern

- Improved code readability and maintainability: Clear separation of success and failure paths.
- Explicit error handling: Forces developers to consider both success and failure scenarios.
- Type-safe error propagation: Errors are part of the return type, making them harder to ignore.
- Easier testing: Success and failure scenarios can be easily unit tested.

3. Implementing the Result Pattern

Let's start by defining a generic Result class:

```csharp
public class Result<T>
{
    public bool IsSuccess { get; }
    public T Value { get; }
    public string Error { get; }

    private Result(bool isSuccess, T value, string error)
    {
        IsSuccess = isSuccess;
        Value = value;
        Error = error;
    }

    public static Result<T> Success(T value) => new Result<T>(true, value, null);
    public static Result<T> Failure(string error) => new Result<T>(false, default, error);
}
```

Now, let's see how to use this pattern in different layers:

Data Access Layer:

```csharp
public class UserRepository : IUserRepository
{
    public async Task<Result<User>> GetUserByIdAsync(int id)
    {
        try
        {
            var user = await _dbContext.Users.FindAsync(id);
            if (user == null)
                return Result<User>.Failure("User not found");
            return Result<User>.Success(user);
        }
        catch (Exception ex)
        {
            // Log the exception
            return Result<User>.Failure($"An error occurred while fetching the user: {ex.Message}");
        }
    }
}
```

Service Layer:

```csharp
public class UserService : IUserService
{
    private readonly IUserRepository _userRepository;

    public UserService(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }

    public async Task<Result<UserDto>> GetUserByIdAsync(int id)
    {
        var result = await _userRepository.GetUserByIdAsync(id);
        if (!result.IsSuccess)
            return Result<UserDto>.Failure(result.Error);

        var userDto = MapToDto(result.Value);
        return Result<UserDto>.Success(userDto);
    }

    private UserDto MapToDto(User user)
    {
        // Mapping logic here
    }
}
```

Controller (Presentation Layer):

```csharp
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IUserService _userService;

    public UsersController(IUserService userService)
    {
        _userService = userService;
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetUser(int id)
    {
        var result = await _userService.GetUserByIdAsync(id);
        if (!result.IsSuccess)
            return BadRequest(result.Error);

        return Ok(result.Value);
    }
}
```

4. Error Handling with the Result Pattern

Different types of errors can be represented using a more detailed Error type:

```csharp
public class Error
{
    public string Code { get; }
    public string Message { get; }

    public Error(string code, string message)
    {
        Code = code;
        Message = message;
    }
}

public class Result<T>
{
    public bool IsSuccess { get; }
    public T Value { get; }
    public Error Error { get; }

    // ... rest of the implementation
}
```

Handling errors in different layers:

Data Access Layer: Log detailed technical errors, return user-friendly messages.
Service Layer: Aggregate errors from multiple operations, translate technical errors to domain-specific errors.
Presentation Layer: Map errors to appropriate HTTP status codes, format error responses.

Best practices for logging and communicating errors:

```csharp
public class UserService : IUserService
{
    private readonly IUserRepository _userRepository;
    private readonly ILogger<UserService> _logger;

    public UserService(IUserRepository userRepository, ILogger<UserService> logger)
    {
        _userRepository = userRepository;
        _logger = logger;
    }

    public async Task<Result<UserDto>> GetUserByIdAsync(int id)
    {
        var result = await _userRepository.GetUserByIdAsync(id);
        if (!result.IsSuccess)
        {
            _logger.LogWarning("Failed to retrieve user with ID {UserId}. Error: {ErrorMessage}", id, result.Error.Message);
            return Result<UserDto>.Failure(new Error("USER_NOT_FOUND", "The requested user could not be found."));
        }

        var userDto = MapToDto(result.Value);
        return Result<UserDto>.Success(userDto);
    }
}
```

5. Additional Considerations

Here's a more complex example showing how to chain operations using the Result pattern:

```csharp
public class OrderService : IOrderService
{
    private readonly IOrderRepository _orderRepository;
    private readonly IPaymentGateway _paymentGateway;
    private readonly IEmailService _emailService;

    public async Task<Result<OrderDto>> PlaceOrderAsync(OrderRequest request)
    {
        // Validate order
        var validationResult = ValidateOrder(request);
        if (!validationResult.IsSuccess)
            return Result<OrderDto>.Failure(validationResult.Error);

        // Create order
        var createOrderResult = await _orderRepository.CreateOrderAsync(request);
        if (!createOrderResult.IsSuccess)
            return Result<OrderDto>.Failure(createOrderResult.Error);

        // Process payment
        var paymentResult = await _paymentGateway.ProcessPaymentAsync(createOrderResult.Value.Id, request.PaymentDetails);
        if (!paymentResult.IsSuccess)
        {
            await _orderRepository.CancelOrderAsync(createOrderResult.Value.Id);
            return Result<OrderDto>.Failure(paymentResult.Error);
        }

        // Send confirmation email
        var emailResult = await _emailService.SendOrderConfirmationAsync(createOrderResult.Value.Id);
        if (!emailResult.IsSuccess)
        {
            // Log the email failure, but don't fail the entire operation
            _logger.LogWarning("Failed to send order confirmation email for order {OrderId}", createOrderResult.Value.Id);
        }

        var orderDto = MapToDto(createOrderResult.Value);
        return Result<OrderDto>.Success(orderDto);
    }
}
```

This example demonstrates how the Result pattern can be used to handle complex workflows with multiple potential points of failure. It allows for clear error propagation and handling at each step of the process.

In conclusion, the Result pattern provides a powerful and flexible approach to error handling in layered architectures. It improves code readability, maintainability, and testability while providing clear and explicit error handling. When implemented consistently across an application, it can significantly enhance the robustness and reliability of your software.