Published on

Result pattern для управления потоком

Authors
  • avatar
    Name
    Николай Маракасов
    Twitter
    @Twitter

Паттерн 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 делает цепочки вызовов чистыми и наглядными.

Минусы:

  • Нужно рефакторить текущий код.
  • Необходимо освоить новый подход.
  • Появляются небольшие накладные расходы на типы-обёртки.