Помилки — це не винятки: патерн 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> і дисципліна його повертати. Винагорода —
кодбаза, де система типів, а не племінні знання, каже тобі, як кожна
операція може впасти. На соло-проєкті, де нема кому пояснювати, це не
приємний бонус — це єдина документація, яка не застаріє.
Будуєте щось подібне?
Розкажіть, над чим працюєте. Беру невелику кількість проєктів одночасно.