Ошибки — это не исключения: паттерн Result, который держит API честным
Бросать исключение на «не найдено» или ошибку валидации — значит превратить счастливый путь в минное поле невидимых выходов. Верни ошибку как значение — и компилятор начнёт работать на тебя.
Почти в каждой .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> и дисциплина его возвращать. Награда — кодовая
база, где система типов, а не племенные знания, говорит тебе, как каждая
операция может упасть. На соло-проекте, где некому объяснять, это не
приятный бонус — это единственная документация, которая не устареет.
Строите что-то похожее?
Расскажите, над чем работаете. Беру небольшое число проектов одновременно.