Oleg Katrichuk
Назад к журналу
·3 мин чтения

Ошибки — это не исключения: паттерн Result, который держит API честным

Бросать исключение на «не найдено» или ошибку валидации — значит превратить счастливый путь в минное поле невидимых выходов. Верни ошибку как значение — и компилятор начнёт работать на тебя.

.NETАрхитектураОбработка ошибокCQRS

Почти в каждой .NET-кодовой базе есть строка, которая тихо тебе врёт:

var order = await _repo.GetByIdAsync(id)
    ?? throw new NotFoundException($"Order {id} not found");

Выглядит нормально. Даже кажется защитной. Но она превратила совершенно обычный исход — того, что ты просил, просто нет — в исключение, а исключения не являются потоком управления. Это пожарная сигнализация. Дёрни эту сигнализацию на каждую отсутствующую строку, каждый плохой ввод, каждый дублирующийся email — и твои обработчики обрастут ореолом невидимых выходов, в которых не признаётся ни одна сигнатура. Через полгода никто уже не скажет, какие из этих throw означают «пользователь сделал что-то нормальное», а какие — «база горит».

На PetZone — .NET-бэкенде на чистой архитектуре за многоязычным React SPA — я провёл черту сразу: исключения для неожиданного, а ожидаемые ошибки — это значения. Их возвращают, а не бросают.

Как на деле выглядит «ошибка как значение»

Каждый обработчик команды или запроса возвращает Result<T>. Ошибка несёт типизированный код, поэтому одно и то же значение мапится на уровень логирования, на ProblemDetails и на HTTP-статус — без единого catch.

public async Task<Result<ListingDto>> Handle(GetListing q, CancellationToken ct)
{
    var listing = await _repo.FindAsync(q.Id, ct);
    if (listing is null)
        return Result.Failure<ListingDto>(Error.NotFound("Listing.NotFound"));

    if (listing.OwnerId != q.RequesterId)
        return Result.Failure<ListingDto>(Error.Forbidden("Listing.Forbidden"));

    return Result.Success(listing.ToDto());
}

Здесь нет ничего нового. Важно то, что это убирает: нет try, нет catch, нет типа исключения, который надо не забыть поймать двумя слоями выше, и — самое главное — сигнатура говорит правду. Task<Result<T>> говорит: «это может упасть, и ты обязан это обработать». А Task<T> со скрытым throw говорит: «поверь мне».

Граница переводит ошибку один раз

Тебе нужно лишь одно место, которое знает, как типизированная ошибка становится HTTP-ответом. У меня это одно расширение на стороне эндпоинта:

return result.Match(
    onSuccess: dto => TypedResults.Ok(dto),
    onFailure: err => err.Code switch
    {
        var c when c.EndsWith("NotFound")  => TypedResults.NotFound(err.ToProblem()),
        var c when c.EndsWith("Forbidden") => TypedResults.Forbid(),
        var c when c.EndsWith("Conflict")  => TypedResults.Conflict(err.ToProblem()),
        _                                  => TypedResults.UnprocessableEntity(err.ToProblem()),
    });

Сравни это с обработкой на исключениях, где маппинг живёт в глобальном фильтре, которому приходится реверс-инженерить намерение из типа исключения — и где новый SomethingWentWrongException, который никто не зарегистрировал, проваливается в 500 со стектрейсом в теле ответа.

За что исключения всё ещё отвечают

Это не «исключения — зло». Оборванный коннект к базе, кривой конфиг на старте, баг, нарушивший инвариант — такие вещи должны бросаться, громко, и лететь в глобальный обработчик. Правило простое и умещается в одну строку:

Если ошибку можно предсказать — верни её. Если она означает, что что-то сломалось — брось её.

«Заказ не найден» — предсказуемо. «Postgres отказал в коннекте» — сломано. Первое — это Result, второе — исключение. Как только ты держишь эту черту, число try/catch сжимается до горстки на настоящих границах — и каждый из них что-то означает.

Где это окупается так, что в демо не увидишь

Выигрыш не в отдельном обработчике — он в том, что не случается потом:

  • Нет невидимых выходов. Ревьюер, читая Task<Result<T>>, знает режимы падения, не запуская код. Скрытый throw на три вызова вглубь находят в 2 ночи, а не на ревью.
  • Фронтенд получает контракт. Поскольку каждая ошибка — типизированный payload, React-клиент распаковывает один консистентный конверт, а не гадает, есть ли у 400 сообщение, список полей или пустое тело.
  • Логи перестают врать. Ожидаемые ошибки логируются на Information с кодом; на Error доходят только настоящие исключения. Твой алертинг наконец может доверять: лог уровня Error означает, что что-то действительно не так.

Цена — тип Result<T> и дисциплина его возвращать. Награда — кодовая база, где система типов, а не племенные знания, говорит тебе, как каждая операция может упасть. На соло-проекте, где некому объяснять, это не приятный бонус — это единственная документация, которая не устареет.

Строите что-то похожее?

Расскажите, над чем работаете. Беру небольшое число проектов одновременно.