- Published on
Result pattern для управления потоком
- Authors
- Name
- Николай Маракасов
Result
Паттерн Эта идея пришла к нам из мира функционального программирования, где есть монады, моноиды, аппликативы, функторы и прочие чудеса. В эти дебри мы углубляться не будем — всё-таки C# не является функциональным языком, и встроенной поддержки этих абстракций там нет.
Result
— это попытка уйти от использования исключений для управления потоком выполнения программы.
Пример с использованием исключений
public class Order
{
// ... здесь всякие поля
public void Complete(Audit updated)
{
if(updated == null)
throw new ArgumentException("Updated cannot be null");
if (Status == OrderStatus.Completed)
throw new CompleteOrderException("Order already in Completed state");
Status = OrderStatus.Completed;
Updated = updated;
}
}
public sealed class CompleteOrderCommandHandler(IOrderRepository repository, IUserService userService, ILogger<CompleteOrderCommandHandler> logger)
: IRequestHandler<CompleteOrderCommand>
{
public async Task Handle(CompleteOrderCommand request, ct ct)
{
var order = await repository.GetById(request.Id, ct);
if (order is null)
throw new OrderNotFoundException($"Order not found, Id: {request.Id}");
try
{
order.Complete(new Audit(userService.Id));
}
catch (CompleteOrderException ex)
{
logger.LogError("Failed to complete order", ex);
throw;
}
await repository.Update(order, ct);
}
}
В этом примере при попытке повторно завершить заказ выбрасывается исключение. Его необходимо обработать на уровне вызывающего кода. Это неочевидно: метод ничего не возвращает, и чтобы понять его поведение, нужно заглядывать внутрь. Кроме того, генерация исключения — затратная операция.
Result
Альтернатива с использованием Реализуем тот же сценарий с использованием Result
. Изменим возвращаемый тип метода и уберем выброс исключений. Репозиторий тоже адаптируем:
public class Order
{
// ... поля
public Result Complete(Audit updated)
{
if (Status == OrderStatus.Completed)
return Result.Fail("Order already in Completed state");
if(updated == null)
return Result<Unit>.Fail("Updated cannot be null");
Status = OrderStatus.Completed;
Updated = updated;
return Result.Success();
}
}
public sealed class OrderRepository(CleanArchDbContext dbContext) : IOrderRepository
{
public async Task<Result<Order>> GetById(Guid requestId, ct ct)
{
var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == requestId, ct);
return order == null
? Result<Order>.NotFound($"Order not found, Id: {requestId}")
: Result<Order>.Success(order);
}
public async Task<Result<Unit>> Update(Order order, ct ct)
{
dbContext.Orders.Update(order);
var result = await dbContext.SaveChangesAsync(ct);
return result == 0
? Result<Unit>.Fail("No changes detected")
: Result<Unit>.Success(Unit.Value);
}
}
public sealed class CompleteOrderCommandHandler(IOrderRepository repository, IUserService userService, ILogger<CompleteOrderCommandHandler> logger)
: IRequestHandler<CompleteOrderCommand, Result<Unit>>
{
public async Task<Result<Unit>> Handle(CompleteOrderCommand request, ct ct)
{
var orderResult = await repository.GetById(request.Id, ct);
if (!orderResult.IsSuccess)
return Result<Unit>.Error(orderResult.ErrorDetails);
// ...
}
}
Теперь каждый вызов нужно оборачивать в проверку IsSuccess
. Такое решение на первый взгляд может казаться избыточным, добавляет «шум» в код. Но есть хорошие новости — это можно упростить двумя способами:
- Использовать методы-расширения
Bind
,Map
для объединения вызовов в цепочку. - Реализовать
Select
иSelectMany
для поддержки LINQ-синтаксиса.
Bind
и Map
Цепочки с Методы Bind
и Map
позволяют связать вызовы в цепочку:
public Task<Result<Unit>> Handle(CompleteOrderCommand request, ct ct)
{
var result = repository.GetById(request.Id, ct)
.Bind(order => userService.Id)
// ...
.Map(order => order.Complete())
.Map(_ => repository.Update(order, ct));
}
Но здесь мы сталкиваемся с ограничением: результат предыдущего шага передаётся дальше, и тип может не совпадать с тем, что нужно. Например: Result<Order> -> Result<string>
— и это ломает дальнейшее связывание.
Поэтому я предпочитаю второй подход — LINQ.
LINQ-подход
public Task<Result<Unit>> Handle(CompleteOrderCommand request, CancellationToken cancellationToken) =>
from order in repository.GetById(request.Id, cancellationToken)
from userId in userService.Id
from modifier in Audit.Create(userId)
from _ in order.Complete(modifier)
from updResult in repository.Update(order, cancellationToken)
select updResult;
В обоих подходах следующий шаг выполняется только если текущий результат — IsSuccess
. В противном случае выполнение прерывается и возвращается ошибка. Нам не нужны конструкции if-else
— код становится компактнее и выразительнее.
Обработка результата в контроллере
public async Task<IActionResult> Complete(Guid orderId, CancellationToken ct)
{
var result = await mediator.Send(new CompleteOrderCommand(orderId), ct);
return result.Match<IActionResult>(
ok => NoContent(),
error => Problem(error.Message)
);
}
Вывод
Используя Result
, мы пишем более декларативный и читаемый код. Исключения при этом остаются для по-настоящему исключительных ситуаций — например, проблем с инфраструктурой или внешними сервисами.
Плюсы:
- Код становится более выразительным.
- Меньше сюрпризов и скрытых исключений.
- Поддержка LINQ делает цепочки вызовов чистыми и наглядными.
Минусы:
- Нужно рефакторить текущий код.
- Необходимо освоить новый подход.
- Появляются небольшие накладные расходы на типы-обёртки.