Result Pattern trong C#: Xử lý lỗi sạch sẽ mà không cần Exception

Học cách triển khai Result Pattern để xử lý lỗi sạch sẽ hơn mà không cần exception. Khám phá khi nào nên dùng, cách xây dựng từ đầu, và tích hợp với ASP.NET Core Minimal APIs.

Xử lý lỗi có thể quyết định sự rõ ràng và độ tin cậy của code của bạn. Mặc dù exception rất mạnh mẽ, nhưng việc dùng chúng cho những lỗi nghiệp vụ dự kiến sẽ dẫn đến code rối rắm, luồng điều khiển ẩn, và chi phí hiệu năng. Result Pattern mang đến một giải pháp thay thế sạch sẽ hơn: làm cho sự thành công và thất bại trở nên tường minh ngay trong chữ ký (signature) của phương thức.

Vấn đề khi dùng Exception làm luồng điều khiển

Hãy xem xét cách tiếp cận phổ biến sau:

public User GetUserById(int id)
{
    var user = _repository.Find(id);
    if (user is null)
        throw new UserNotFoundException($"User {id} not found");
    return user;
}

Bên gọi (caller) phải bằng cách nào đó biết rằng UserNotFoundException có thể được ném ra. Thông tin này không hiển thị trong chữ ký phương thức, dẫn đến:

  • Luồng điều khiển ẩn: bên gọi phải đọc chi tiết triển khai để biết nên bắt (catch) cái gì.
  • Chi phí hiệu năng: việc tạo exception sẽ thu thập stack trace, rất tốn kém.
  • Ý định không rõ ràng: đây là lỗi lập trình hay một trường hợp nghiệp vụ dự kiến?

Result Pattern là gì?

Result Pattern đóng gói kết quả của một thao tác trong một đối tượng duy nhất, đại diện cho thành công (kèm dữ liệu) hoặc thất bại (kèm chi tiết lỗi). Thay vì ném exception, các phương thức trả về một Result mà bên gọi phải xử lý một cách tường minh.

public Result<User> GetUserById(int id)
{
    var user = _repository.Find(id);
    if (user is null)
        return Result<User>.Failure(Error.NotFound("User.NotFound", $"User {id} not found"));
    return Result<User>.Success(user);
}

Giờ đây chữ ký phương thức cho bạn biết: “Thao tác này có thể thất bại, và đây là cách xử lý nó.”

Khi nào nên dùng cách nào

Tình huống Cách tiếp cận
Mất kết nối database Exception
Lỗi hệ thống file Exception
Hết bộ nhớ Exception
Không tìm thấy người dùng Result
Lỗi validation Result
Vi phạm quy tắc nghiệp vụ Result
Không đủ quyền Result
Đầu vào không hợp lệ Result

Nguyên tắc chung: Dùng exception cho những tình huống thực sự đặc biệt mà bạn không biết cách xử lý. Dùng Result cho những thất bại dự kiến thuộc về logic nghiệp vụ bình thường.

Xây dựng Result Pattern từ đầu

Hãy triển khai một Result Pattern vững chắc từng bước một.

Lớp Error

Đầu tiên, chúng ta cần một cách để biểu diễn lỗi:

public sealed record Error(string Code, string Description)
{
    public static readonly Error None = new(string.Empty, string.Empty);

    // Factory methods cho các loại lỗi phổ biến
    public static Error NotFound(string code, string description) =>
        new NotFoundError(code, description);

    public static Error Validation(string code, string description) =>
        new ValidationError(code, description);

    public static Error Conflict(string code, string description) =>
        new ConflictError(code, description);

    public static Error Unauthorized(string code, string description) =>
        new UnauthorizedError(code, description);

    public static Error Failure(string code, string description) =>
        new(code, description);
}

// Typed errors để phân loại tốt hơn
public sealed record NotFoundError(string Code, string Description) 
    : Error(Code, Description);

public sealed record ValidationError(string Code, string Description) 
    : Error(Code, Description);

public sealed record ConflictError(string Code, string Description) 
    : Error(Code, Description);

public sealed record UnauthorizedError(string Code, string Description) 
    : Error(Code, Description);

Lớp Result (không có giá trị)

Dành cho các thao tác không trả về dữ liệu:

public class Result
{
    public bool IsSuccess { get; }
    public bool IsFailure => !IsSuccess;
    public Error Error { get; }

    protected Result(bool isSuccess, Error error)
    {
        if (isSuccess && error != Error.None)
            throw new ArgumentException("Success result cannot have an error", nameof(error));
        if (!isSuccess && error == Error.None)
            throw new ArgumentException("Failure result must have an error", nameof(error));

        IsSuccess = isSuccess;
        Error = error;
    }

    public static Result Success() => new(true, Error.None);
    public static Result Failure(Error error) => new(false, error);

    // Chuyển đổi ngầm từ Error sang Result
    public static implicit operator Result(Error error) => Failure(error);
}

Lớp Result generic (có giá trị)

Dành cho các thao tác trả về dữ liệu khi thành công:

public class Result<TValue> : Result
{
    private readonly TValue? _value;

    public TValue Value => IsSuccess
        ? _value!
        : throw new InvalidOperationException("Cannot access value of a failed result");

    private Result(TValue value) : base(true, Error.None)
    {
        _value = value;
    }

    private Result(Error error) : base(false, error)
    {
        _value = default;
    }

    public static Result<TValue> Success(TValue value) => new(value);
    public new static Result<TValue> Failure(Error error) => new(error);

    // Chuyển đổi ngầm để cú pháp gọn gàng hơn
    public static implicit operator Result<TValue>(TValue value) => Success(value);
    public static implicit operator Result<TValue>(Error error) => Failure(error);
}

Phương thức mở rộng (Extension Methods) hàm

Để tránh các câu lệnh if lồng nhau, hãy thêm các extension hàm:

public static class ResultExtensions
{
    // Biến đổi giá trị nếu thành công
    public static Result<TOut> Map<TIn, TOut>(
        this Result<TIn> result,
        Func<TIn, TOut> mapper)
    {
        return result.IsSuccess
            ? Result<TOut>.Success(mapper(result.Value))
            : Result<TOut>.Failure(result.Error);
    }

    // Nối chuỗi các thao tác trả về Result
    public static Result<TOut> Bind<TIn, TOut>(
        this Result<TIn> result,
        Func<TIn, Result<TOut>> binder)
    {
        return result.IsSuccess
            ? binder(result.Value)
            : Result<TOut>.Failure(result.Error);
    }

    // Xử lý cả hai trường hợp thành công và thất bại
    public static TOut Match<TIn, TOut>(
        this Result<TIn> result,
        Func<TIn, TOut> onSuccess,
        Func<Error, TOut> onFailure)
    {
        return result.IsSuccess
            ? onSuccess(result.Value)
            : onFailure(result.Error);
    }

    // Thực thi hành động khi thành công
    public static Result<T> Tap<T>(
        this Result<T> result,
        Action<T> action)
    {
        if (result.IsSuccess)
            action(result.Value);
        return result;
    }

    // Chuyển Result thành Result<T>
    public static Result<T> ToResult<T>(this Result result, T value)
    {
        return result.IsSuccess
            ? Result<T>.Success(value)
            : Result<T>.Failure(result.Error);
    }
}

Sử dụng Result Pattern

Cách dùng cơ bản

public class UserService
{
    private readonly IUserRepository _repository;

    public Result<User> GetById(int id)
    {
        var user = _repository.Find(id);
        
        if (user is null)
            return Error.NotFound("User.NotFound", $"User with ID {id} was not found");

        return user; // Chuyển đổi ngầm sang Result<User>.Success
    }

    public Result<User> Create(CreateUserRequest request)
    {
        // Validation
        if (string.IsNullOrWhiteSpace(request.Email))
            return Error.Validation("User.EmailRequired", "Email is required");

        if (_repository.ExistsByEmail(request.Email))
            return Error.Conflict("User.EmailExists", "A user with this email already exists");

        // Tạo user
        var user = new User(request.Name, request.Email);
        _repository.Add(user);

        return user;
    }
}

Nối chuỗi thao tác với Bind

public Result<OrderConfirmation> ProcessOrder(CreateOrderRequest request)
{
    return ValidateOrder(request)
        .Bind(order => CheckInventory(order))
        .Bind(order => ProcessPayment(order))
        .Bind(order => CreateShipment(order))
        .Map(shipment => new OrderConfirmation(shipment.TrackingNumber));
}

private Result<Order> ValidateOrder(CreateOrderRequest request)
{
    if (request.Items.Count == 0)
        return Error.Validation("Order.NoItems", "Order must contain at least one item");
    
    return new Order(request.CustomerId, request.Items);
}

private Result<Order> CheckInventory(Order order)
{
    foreach (var item in order.Items)
    {
        if (!_inventory.IsAvailable(item.ProductId, item.Quantity))
            return Error.Conflict("Order.OutOfStock", $"Product {item.ProductId} is out of stock");
    }
    return order;
}

Sử dụng Match để xử lý gọn gàng

var message = userService.GetById(userId).Match(
    onSuccess: user => $"Welcome, {user.Name}!",
    onFailure: error => $"Error: {error.Description}"
);

Tích hợp với ASP.NET Core Minimal APIs

Result Pattern tích hợp tuyệt vời với Minimal APIs và ProblemDetails:

Phương thức mở rộng cho API Responses

public static class ResultApiExtensions
{
    public static IResult ToApiResult<T>(this Result<T> result)
    {
        if (result.IsSuccess)
            return Results.Ok(result.Value);

        return result.Error switch
        {
            NotFoundError => Results.NotFound(CreateProblemDetails(result.Error, 404)),
            ValidationError => Results.BadRequest(CreateProblemDetails(result.Error, 400)),
            ConflictError => Results.Conflict(CreateProblemDetails(result.Error, 409)),
            UnauthorizedError => Results.Json(
                CreateProblemDetails(result.Error, 401),
                statusCode: 401),
            _ => Results.BadRequest(CreateProblemDetails(result.Error, 400))
        };
    }

    public static IResult ToApiResult(this Result result, object? successValue = null)
    {
        if (result.IsSuccess)
            return successValue is not null 
                ? Results.Ok(successValue) 
                : Results.Ok();

        return result.Error switch
        {
            NotFoundError => Results.NotFound(CreateProblemDetails(result.Error, 404)),
            ValidationError => Results.BadRequest(CreateProblemDetails(result.Error, 400)),
            ConflictError => Results.Conflict(CreateProblemDetails(result.Error, 409)),
            _ => Results.BadRequest(CreateProblemDetails(result.Error, 400))
        };
    }

    public static IResult ToCreatedResult<T>(
        this Result<T> result,
        string location)
    {
        if (result.IsSuccess)
            return Results.Created(location, result.Value);

        return result.ToApiResult();
    }

    private static ProblemDetails CreateProblemDetails(Error error, int statusCode) =>
        new()
        {
            Status = statusCode,
            Title = error.Code,
            Detail = error.Description,
            Type = $"https://httpstatuses.com/{statusCode}"
        };
}

Sử dụng trong Endpoints

var app = builder.Build();

app.MapGet("/api/users/{id}", (int id, UserService userService) =>
{
    return userService.GetById(id).ToApiResult();
});

app.MapPost("/api/users", (CreateUserRequest request, UserService userService) =>
{
    return userService.Create(request)
        .ToCreatedResult($"/api/users/{request.Email}");
});

app.MapPost("/api/orders", (CreateOrderRequest request, OrderService orderService) =>
{
    return orderService.ProcessOrder(request).Match(
        onSuccess: confirmation => Results.Ok(confirmation),
        onFailure: error => error switch
        {
            ValidationError => Results.BadRequest(new { error.Code, error.Description }),
            ConflictError => Results.Conflict(new { error.Code, error.Description }),
            _ => Results.Problem(error.Description)
        }
    );
});

Tập trung hoá Domain Errors

Giữ tất cả lỗi nghiệp vụ (domain errors) ở một nơi để nhất quán:

public static class DomainErrors
{
    public static class User
    {
        public static Error NotFound(int id) =>
            Error.NotFound("User.NotFound", $"User with ID {id} was not found");

        public static Error EmailAlreadyExists(string email) =>
            Error.Conflict("User.EmailExists", $"Email {email} is already registered");

        public static Error InvalidEmail =>
            Error.Validation("User.InvalidEmail", "The email format is invalid");

        public static Error PasswordTooWeak =>
            Error.Validation("User.PasswordTooWeak", 
                "Password must be at least 8 characters with uppercase, lowercase, and digits");
    }

    public static class Order
    {
        public static Error NotFound(Guid id) =>
            Error.NotFound("Order.NotFound", $"Order {id} was not found");

        public static Error EmptyCart =>
            Error.Validation("Order.EmptyCart", "Cannot create order with empty cart");

        public static Error InsufficientStock(string productId) =>
            Error.Conflict("Order.InsufficientStock", $"Insufficient stock for product {productId}");

        public static Error PaymentFailed(string reason) =>
            Error.Failure("Order.PaymentFailed", $"Payment failed: {reason}");
    }
}

Cách sử dụng trở nên rất sạch sẽ:

public Result<User> GetById(int id)
{
    var user = _repository.Find(id);
    return user is null 
        ? DomainErrors.User.NotFound(id) 
        : user;
}

Thư viện của bên thứ ba

Nếu bạn không muốn tự xây dựng, hãy cân nhắc các thư viện sau:

FluentResults

// Cài đặt: dotnet add package FluentResults

Result<User> result = Result.Ok(user);
Result<User> failure = Result.Fail<User>("User not found");

// Nối chuỗi
var final = await Result.Try(async () => await GetUserAsync())
    .Bind(user => ValidateUser(user))
    .Map(user => new UserDto(user));

CSharpFunctionalExtensions

// Cài đặt: dotnet add package CSharpFunctionalExtensions

Result<User> result = Result.Success(user);
Result<User> failure = Result.Failure<User>("User not found");

// Tự động bọc exception
var result = await Result.Try(async () => await _api.CallAsync());

Best Practices

Giữ nó ở tầng nghiệp vụ (Business Layer)

Sử dụng Result Pattern ở tầng service/domain của bạn. Đừng dùng nó trong repository (hãy để chúng ném exception cho các lỗi database) hoặc controller (hãy chuyển đổi sang HTTP response).

Repository (ném exception) 
    → Service (trả về Results) 
        → Controller/Endpoint (chuyển đổi sang HTTP)

Đừng lạm dụng

Không phải phương thức nào cũng cần trả về Result. Những truy vấn đơn giản không nên thất bại có thể trả về giá trị trực tiếp:

// Điều này là ổn - không cần Result
public string GetUserDisplayName(User user) => $"{user.FirstName} {user.LastName}";

Fail Fast trong môi trường phát triển

Cân nhắc thêm một helper cho các tình huống phát triển:

public static T GetValueOrThrow<T>(this Result<T> result)
{
    if (result.IsFailure)
        throw new InvalidOperationException($"Result failed: {result.Error.Description}");
    return result.Value;
}

Validate nhiều trường

Đối với validation có nhiều lỗi:

public class ValidationResult : Result
{
    public IReadOnlyList<Error> Errors { get; }

    private ValidationResult(IReadOnlyList<Error> errors) 
        : base(errors.Count == 0, errors.FirstOrDefault() ?? Error.None)
    {
        Errors = errors;
    }

    public static ValidationResult WithErrors(params Error[] errors) =>
        new(errors);

    public static ValidationResult Ok() => new(Array.Empty<Error>());
}

Kết luận

Result Pattern mang lại sự rõ ràng cho việc xử lý lỗi bằng cách làm cho các thất bại trở nên tường minh trong chữ ký phương thức. Nó không phải là sự thay thế cho exception — mà là một sự bổ sung để xử lý các thất bại nghiệp vụ dự kiến một cách khéo léo.

Những điểm chính:

  • Dùng exception cho những tình huống thực sự đặc biệt (lỗi I/O, trạng thái hỏng hóc).
  • Dùng Result cho những thất bại dự kiến (validation, không tìm thấy, quy tắc nghiệp vụ).
  • Giữ định nghĩa lỗi tập trung để nhất quán.
  • Dùng các extension method (Map, Bind, Match) để nối chuỗi gọn gàng.
  • Tích hợp với ASP.NET Core bằng ProblemDetails cho API responses.

Result Pattern có thể thêm chút nghi thức (ceremony), nhưng những lợi ích — xử lý lỗi tường minh, code tự mô tả, và luồng điều khiển sạch hơn — khiến nó xứng đáng cho bất kỳ ứng dụng nghiêm túc nào.