EngineeringSenior DeveloperTraining Sessions

Session 07 - Error Handling: Exception Strategy Cho Production

Dev hay dùng try/catch một cách "bản năng": bao hết, catch hết, swallow hết. Hoặc ngược lại: throw exception cho MỌI validation error. Cả hai đều sai. Session này giúp bạn thiết kế error handling có chiến lược.


Mục tiêu

  • Phân biệt expected errors vs unexpected exceptions
  • Biết khi nào throw, khi nào return Result
  • Thiết kế global error handling đúng cách
  • Tránh các anti-patterns phổ biến

1) Hai Loại Lỗi - Phải Phân Biệt

Expected Errors (Business Errors):
  - User nhập sai email
  - Sản phẩm hết hàng
  - Đơn hàng đã bị hủy
  → KHÔNG phải exception! Đây là flow BÌNH THƯỜNG.
  → Trả Result/Error object.

Unexpected Exceptions (System Errors):
  - Database connection timeout
  - Null reference (bug)
  - Out of memory
  → ĐÚNG LÀ exception! Không lường trước được.
  → Throw exception, catch ở global handler.

Tại sao phân biệt?

csharp
// ❌ Dùng Exception cho business errors
public Order CreateOrder(OrderRequest req)
{
    if (req.Items.Count == 0)
        throw new ValidationException("Order must have items");
    // Exception = tốn kém (stack trace, unwinding)
    // Exception = control flow (anti-pattern!)
    // Exception = caller PHẢI try/catch → messy code
}

// ✅ Dùng Result cho business errors
public Result<Order> CreateOrder(OrderRequest req)
{
    if (req.Items.Count == 0)
        return Result.Failure<Order>("Order must have items");
    // Rẻ (no stack trace)
    // Explicit (caller BIẾT phải check result)
    // Clean flow (no try/catch)
}

2) Result Pattern - Cách Xử Lý Business Errors

csharp
// Minimal Result pattern
public class Result<T>
{
    public T? Value { get; }
    public string? Error { get; }
    public bool IsSuccess => Error is null;

    private Result(T? value, string? error)
    {
        Value = value;
        Error = error;
    }

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

// Usage trong handler
public async Task<Result<OrderDto>> CreateOrder(CreateOrderCommand cmd)
{
    var product = await _productRepo.GetByIdAsync(cmd.ProductId);
    if (product is null)
        return Result<OrderDto>.Failure("Product not found");

    if (product.Stock < cmd.Quantity)
        return Result<OrderDto>.Failure("Insufficient stock");

    var order = Order.Create(cmd.CustomerId, product, cmd.Quantity);
    await _orderRepo.AddAsync(order);

    return Result<OrderDto>.Success(order.ToDto());
}

// Controller
[HttpPost]
public async Task<IActionResult> Create(CreateOrderCommand cmd)
{
    var result = await _handler.CreateOrder(cmd);
    return result.IsSuccess
        ? Ok(result.Value)
        : BadRequest(new { error = result.Error });
    // Clean! Không try/catch.
}

3) Exception - Chỉ Cho Unexpected Errors

csharp
// Rule: Exception = điều KHÔNG LƯỜNG TRƯỚC xảy ra

// ❌ Catch rồi nuốt
try
{
    await db.SaveChangesAsync();
}
catch (Exception)
{
    // Nuốt exception → bug ẩn, data corruption
    // KHÔNG BAO GIỜ catch exception mà không xử lý!
}

// ❌ Catch quá rộng rồi làm gì đó sai
try
{
    var user = await GetUserAsync(id);
}
catch (Exception ex)
{
    return null; // Trả null → NullReferenceException ở chỗ khác → khó debug!
}

// ✅ Let it bubble up → Global handler bắt
// KHÔNG catch ở đây trừ khi bạn CÓ THỂ xử lý meaningfully
var user = await GetUserAsync(id); // Nếu fail → exception bubble up

Khi nào NÊN try/catch?

csharp
// 1. External call CÓ THỂ fail và bạn có FALLBACK
try
{
    var rate = await exchangeRateApi.GetRate("USD", "VND");
    return rate;
}
catch (HttpRequestException)
{
    // Fallback: dùng cached rate
    return _cache.GetLastKnownRate("USD", "VND");
}

// 2. Resource cleanup (should use `using` thay thế)
// 3. Retry logic (should use Polly thay thế)

// Rule: TRY/CATCH = bạn CÓ HÀNH ĐỘNG cụ thể cho exception đó
// Nếu chỉ log → để global handler lo

4) Global Exception Handling - 1 Nơi Duy Nhất

csharp
// ASP.NET Core: IExceptionHandler (.NET 8+)
public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken ct)
    {
        var (statusCode, message) = exception switch
        {
            ValidationException ex => (400, ex.Message),
            NotFoundException ex => (404, ex.Message),
            UnauthorizedAccessException => (403, "Forbidden"),
            _ => (500, "Internal server error")
        };

        logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);

        httpContext.Response.StatusCode = statusCode;
        await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = statusCode,
            Title = message,
            // ⚠️ KHÔNG expose stack trace trong production!
            Detail = httpContext.RequestServices
                .GetRequiredService<IHostEnvironment>().IsDevelopment()
                ? exception.StackTrace
                : null
        }, ct);

        return true; // Exception handled
    }
}

// Registration
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
app.UseExceptionHandler();

5) Custom Exception Hierarchy - Giữ Gọn

csharp
// ❌ Over-engineering: 50 exception types
public class UserNotFoundException : Exception { }
public class UserAlreadyExistsException : Exception { }
public class UserEmailInvalidException : Exception { }
// ... quá nhiều!

// ✅ Giữ gọn: 2-3 base exception types + Result pattern
// Layer 1: Business errors → Result pattern (không throw)
// Layer 2: Application exceptions → 2-3 types
public class NotFoundException(string entity, object key)
    : Exception($"{entity} with key '{key}' not found");

public class ConflictException(string message)
    : Exception(message);

public class ForbiddenException(string message)
    : Exception(message);

// Tất cả những thứ khác → default 500 bởi global handler

6) Validation - Ở Đâu, Bằng Gì?

Request validation (API boundary):
  → FluentValidation / Data Annotations
  → Validate TRƯỚC khi vào business logic
  → Return 400 Bad Request

Business validation (Domain):
  → Trong Entity/Domain Service
  → Return Result.Failure (không throw)
  
Infrastructure validation:
  → Try/catch cho external failures (DB, API)
  → Global handler bắt
csharp
// Layer 1: Request validation (middleware/filter)
public class CreateOrderValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.ProductId).NotEmpty();
        RuleFor(x => x.Quantity).GreaterThan(0);
    }
}
// → Fail = 400 Bad Request (trước khi code chạy)

// Layer 2: Business validation (domain)
public Result<Order> CreateOrder(Guid productId, int quantity)
{
    var product = await _repo.FindAsync(productId);
    if (product is null)
        return Result.Failure<Order>("Product not found");
    if (product.Stock < quantity)
        return Result.Failure<Order>("Insufficient stock");
    // ...
}
// → Fail = 400/422 (business rule violated)

// Layer 3: Infrastructure (global handler)
// DbException, HttpRequestException → 500/503

Master Checklist ✅

□ Business errors → Result pattern (không throw exception)?
□ Unexpected errors → let bubble up, global handler bắt?
□ Không catch rồi nuốt (empty catch)?
□ Không catch Exception quá rộng (trừ global handler)?
□ Global handler log error VÀ trả ProblemDetails?
□ Production KHÔNG expose stack trace?
□ Validation ở đúng layer (request → business → infrastructure)?
□ Custom exceptions ≤5 types?

Tư duy master: "Nếu tôi catch exception này, tôi sẽ làm gì CỤ THỂ?" - Nếu trả lời được (retry, fallback, custom response) → catch. Nếu chỉ "log rồi throw lại" → để global handler lo.