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.
Mục lụ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.



