Chapter 02

Exception Handling
Deep Dive

IExceptionHandler, ProblemDetails, Custom Exceptions — xử lý lỗi bài bản

IExceptionHandler (.NET 8+)

Đây là cách được khuyến nghị từ .NET 8. DI-friendly, có thể chain nhiều handlers, dễ test.
C#
public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(
        ILogger<GlobalExceptionHandler> logger)
        => _logger = logger;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken ct)
    {
        _logger.LogError(exception,
            "Unhandled exception: {Message}",
            exception.Message);

        var (statusCode, title) = exception switch
        {
            NotFoundException      => (404, "Not Found"),
            ValidationException e  => (400, "Validation Failed"),
            ForbiddenException     => (403, "Forbidden"),
            UnauthorizedAccessException => (401, "Unauthorized"),
            _ => (500, "Internal Server Error")
        };

        httpContext.Response.StatusCode = statusCode;

        await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = statusCode,
            Title = title,
            Detail = exception.Message,
            Instance = httpContext.Request.Path
        }, ct);

        return true; // true = exception đã được xử lý
    }
}

Đăng ký trong Program.cs

C#
var builder = WebApplication.CreateBuilder(args);

// Đăng ký exception handler và ProblemDetails
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

var app = builder.Build();

// ĐẶT ĐẦU TIÊN trong pipeline
app.UseExceptionHandler();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
TryHandleAsync trả về true nếu đã xử lý xong exception. Trả false để pass cho handler tiếp theo trong chain. Bạn có thể đăng ký nhiều IExceptionHandler — chúng chạy theo thứ tự đăng ký.

Custom Exception Middleware (Pre-.NET 8)

Với .NET 6/7 hoặc khi cần toàn quyền kiểm soát:

C#
public class ExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionMiddleware> _logger;

    public ExceptionMiddleware(
        RequestDelegate next,
        ILogger<ExceptionMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context); // Gọi middleware tiếp theo
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception");
            await HandleExceptionAsync(context, ex);
        }
    }

    private static async Task HandleExceptionAsync(
        HttpContext context, Exception ex)
    {
        context.Response.ContentType = "application/problem+json";
        context.Response.StatusCode = ex switch
        {
            NotFoundException => 404,
            ValidationException => 400,
            _ => 500
        };

        await context.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = context.Response.StatusCode,
            Title = ex.GetType().Name,
            Detail = ex.Message
        });
    }
}

// Đăng ký: app.UseMiddleware<ExceptionMiddleware>();

IExceptionHandler (.NET 8+)

Cách mới, khuyến nghị
  • DI-friendly (constructor injection)
  • Chain nhiều handlers
  • Tích hợp sẵn với ProblemDetails
  • Ít boilerplate code

Custom Middleware

Cách cũ, toàn quyền kiểm soát
  • Hoạt động mọi version .NET
  • Toàn quyền kiểm soát response
  • Chỉ một handler duy nhất
  • Nhiều boilerplate hơn

Exception Filters

Exception Filters chỉ hoạt động trong phạm vi MVC pipeline — từ Resource Filters đến Action execution:

Auth Filters
ngoài scope
Resource
trong scope
Action
trong scope
Exception Filter
bắt tại đây
C#
public class ApiExceptionFilter : IExceptionFilter
{
    private readonly ILogger<ApiExceptionFilter> _logger;

    public ApiExceptionFilter(ILogger<ApiExceptionFilter> logger)
        => _logger = logger;

    public void OnException(ExceptionContext context)
    {
        _logger.LogError(context.Exception,
            "Exception in {ActionName}",
            context.ActionDescriptor.DisplayName);

        context.Result = new ObjectResult(new ProblemDetails
        {
            Status = 500,
            Title = "Internal Server Error",
            Detail = context.Exception.Message
        }) { StatusCode = 500 };

        context.ExceptionHandled = true;
    }
}

// Đăng ký global:
// builder.Services.AddControllers(opt =>
//     opt.Filters.Add<ApiExceptionFilter>());
Exception Filters KHÔNG bắt: exception từ middleware (CORS, Auth), model binding errors, routing errors. Luôn kết hợp với UseExceptionHandler() làm lưới an toàn cuối cùng.

ProblemDetails (RFC 7807)

Chuẩn quốc tế cho error response trong API. Thay vì format tùy tiện, mọi lỗi đều có cấu trúc thống nhất:

DON'T — Format tùy tiện

JSON
{
  "error": "Not found",
  "success": false
}

Mỗi API format khác nhau. Client phải xử lý nhiều dạng.

DO — ProblemDetails

JSON
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Not Found",
  "status": 404,
  "detail": "Product with ID 999 was not found",
  "instance": "/api/products/999",
  "traceId": "00-abc123..."
}

Chuẩn RFC 7807. Mọi API cùng format. Content-Type: application/problem+json

C#
// Tùy chỉnh ProblemDetails cho mọi response
builder.Services.AddProblemDetails(opt =>
{
    opt.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] =
            ctx.HttpContext.TraceIdentifier;
        ctx.ProblemDetails.Extensions["nodeId"] =
            Environment.MachineName;
    };
});

Custom Exception Classes

Tạo exception hierarchy rõ ràng giúp pattern matching trong handler trở nên đơn giản:

AppException ← Base exception cho ứng dụng
  ├── NotFoundException → 404
  ├── ValidationException → 400 (kèm danh sách errors)
  ├── BusinessRuleException → 422 (vi phạm business rule)
  ├── ForbiddenException → 403
  └── ConflictException → 409 (duplicate, race condition)
C#
public abstract class AppException : Exception
{
    public abstract int StatusCode { get; }
    protected AppException(string message) : base(message) { }
}

public class NotFoundException : AppException
{
    public override int StatusCode => 404;
    public NotFoundException(string entity, object id)
        : base($"{entity} with ID {id} was not found") { }
}

public class ValidationException : AppException
{
    public override int StatusCode => 400;
    public IDictionary<string, string[]> Errors { get; }

    public ValidationException(
        IDictionary<string, string[]> errors)
        : base("One or more validation failures occurred")
        => Errors = errors;
}

public class BusinessRuleException : AppException
{
    public override int StatusCode => 422;
    public BusinessRuleException(string message)
        : base(message) { }
}

Pattern Matching trong Handler

C#
// Trong GlobalExceptionHandler — clean pattern matching
var statusCode = exception switch
{
    AppException appEx  => appEx.StatusCode, // Dùng StatusCode từ exception
    UnauthorizedAccessException => 401,
    OperationCanceledException => 499, // Client closed request
    _ => 500
};

// Thêm validation errors vào ProblemDetails nếu có
var problemDetails = new ProblemDetails
{
    Status = statusCode,
    Title = exception.GetType().Name,
    Detail = exception.Message,
    Instance = httpContext.Request.Path
};

if (exception is ValidationException validationEx)
{
    problemDetails.Extensions["errors"] = validationEx.Errors;
}
Đặt StatusCode property trên base AppException giúp handler không cần biết từng loại exception cụ thể. Chỉ cần appEx.StatusCode là đủ — mở rộng thêm exception mới mà không sửa handler.