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

Будуєте щось подібне?

Розкажіть, над чим працюєте. Беру невелику кількість проєктів одночасно.