JavaRush /Курси /Spring REST & MVC /Категорії помилок в API

Категорії помилок в API

Spring REST & MVC
Рівень 19 , Лекція 1
Відкрита

1. Категорії помилок і зміст

Якби ми писали консольний застосунок «для себе», можна було б жити за філософією «упало — ну й гаразд». Але REST API — це завжди взаємодія з кимось зовні: фронтендом, мобільним застосунком, інтеграцією чи іншим сервісом. І цьому «комусь» потрібно розуміти, що сталося: він надіслав некоректні дані, запросив неіснуючий ресурс, потрапив у конфлікт станів або сервер спіткнувся об власні ноги.

Найбільша шкода від відсутності категорій у тому, що помилки стають нерозрізнюваними. Клієнт починає вгадувати за рядком повідомлення, що сталося, або просто показує користувачеві «Помилка 500» і йде далі. А серверна команда потім ловить баги на кшталт: «Чому UI показує “сервер недоступний”, коли користувач просто забув заповнити title?». Спойлер: тому що сервер не пояснив, що це помилка входу.

Категорії помилок — це не спроба зробити з вас прихильника табличок в Excel. Це спосіб домовитися про зміст: якщо помилка стосується валідації, клієнт знає, що треба виправити запит; якщо помилка предметна, клієнт розуміє, що запит коректний, але операція заборонена правилами або поточним станом; якщо збій технічний, клієнту залишається лише повторити спробу пізніше або показати «ми вже розбираємося», тому що проблему має виправляти сервер.

Щоб зафіксувати ідею коротко, але по суті, тримаймо таку опору:

Категорія Що означає Хто «винен» за змістом Приклад із Task Tracker API
Помилка входу (validation / binding / parsing) Запит не відповідає контракту Клієнт (або його UI) порожній title, неправильне значення status, пошкоджений JSON
Предметна помилка Запит коректний, але операція порушує правило домену Клієнт намагається виконати заборонену дію спроба змінити архівну задачу
Технічний збій Сервер не зміг коректно виконати сценарій через внутрішню проблему Сервер/інфраструктура баг у коді, неочікуваний null, внутрішня помилка репозиторію

Далі я інколи за звичкою говоритиму validation error, але тут використовуємо це слово широко: мова не лише про Bean Validation, а взагалі про помилки входу/контракту — @Valid, parsing JSON, binding параметрів і type mismatch.

Цей поділ ми зараз розберемо докладно, тому що «знаю три слова» і «вмію класифікувати в реальному коді» — це два різні всесвіти.

2. Де народжуються помилки в API

Коли починаєте працювати зі Spring MVC, здається, ніби все відбувається «в контролері». Наче запит приходить, а далі весь світ — це ваш метод create() або getById(). Насправді до того, як запит дійде до контролера, уже відбувається чимало кроків: розбираються параметри, читається тіло, конвертуються типи, запускається валідація. А після контролера — сервіси, репозиторії та інше доросле життя.

Щоб упевнено ділити помилки на категорії, корисно тримати в голові той самий спрощений пайплайн, але тепер дивитися на нього як на карту майбутніх категорій, а не просто як на список кроків.

flowchart TD
    %% Спрощений пайплайн обробки запиту в API
    A[HTTP-запит] --> B["Зв’язування path/query-параметрів"]
    B --> C["Читання body і JSON -> DTO"]
    C --> D["Bean Validation @Valid"]
    D --> E[Метод контролера]
    E --> F["Сервісний шар: бізнес-правила"]
    F --> G["Repository / сховище в пам’яті"]
    G --> H[Відповідь клієнту]

    %% Точки, де найчастіше виникає помилка входу
    B -->|невідповідність типів| X1[Помилка входу]
    C -->|пошкоджений JSON| X1
    D -->|порушення обмежень| X1

    %% Точки, де найчастіше проявляються доменні причини та збої сервера
    F -->|не знайдено / конфлікт / заборонено| X2[Предметна помилка]
    G -->|збій сховища / баг| X3[Технічний збій]
    F -->|неочікувана ситуація| X3

Зверніть увагу на важливу річ: помилка може статися взагалі без входу у ваш код. Наприклад, клієнт надіслав status=DOING, а в нас enum TaskStatus такого не знає. Spring спробує сконвертувати рядок в enum і не зможе. Це не «помилка бізнес-логіки» і не «помилка сервісу», а помилка на рівні входу.

Те саме з malformed JSON. Ви можете хоч тисячу разів написати if(request == null) throw ..., але якщо JSON зламаний, до request справа не дійде: тіло запиту не розпарсилося.

З іншого боку, є помилки, які принципово з’являються тільки в сервісному шарі. Наприклад, «задача архівна — її не можна змінювати». Тут вхід може бути ідеальним JSON, і всі constraints можуть пройти. Але операція все одно заборонена предметним правилом. Це вже інша категорія.

І, нарешті, є ситуації, коли «все правильно», але сервер ламається сам: баг, неочікуваний стан, внутрішня помилка сховища. Це технічний збій.

Далі ми пройдемося по трьох категоріях так, щоб ви могли не просто назвати їх, а впевнено показати пальцем: «ось тут помилка входу», «ось тут бізнес», «ось тут сервер сам себе зламав».

3. Помилки входу: коли запит «зламався на вході»

Помилки входу — це ті випадки, коли запит не можна прийняти в поточному вигляді. Не «ми не хочемо», не «в нас таке правило», а саме «це не відповідає контракту кінцевої точки». Тут корисно думати як розробник API: ви пообіцяли клієнту певну форму даних і певні допустимі значення, а клієнт цю обіцянку порушив.

Сюди потрапляють два близькі за змістом підтипи: помилки обмежень (Bean Validation) і помилки перетворення/формату (malformed JSON, неправильні типи, неправильні значення enum, некоректні дати). З погляду клієнта це одна і та сама думка: «я надіслав некоректний запит, мені потрібно його виправити». Просто джерело різне: десь спіткнулася валідація, а десь раніше — парсинг або перетворення типів.

Почнемо з того, що вже знайоме: Bean Validation на DTO. Наприклад, create-запит задачі зобов’язаний мати непорожній title довжиною від 3 до 120.

package com.example.tasktracker.api.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

// DTO вхідного запиту на створення задачі.
// Тут фіксуємо контракт API: які поля обов’язкові та які обмеження на них діють.
public record TaskCreateRequest(
        @NotBlank // title обов’язковий: порожній рядок — помилка входу (validation)
        @Size(min = 3, max = 120) // додатково обмежуємо довжину: це теж частина контракту кінцевої точки
        String title
) {
}

Якщо клієнт надішле title: "" або title: "ok", це не предметний конфлікт і не ситуація, коли сервер не зміг щось зробити. Це просто запит, який не проходить за контрактом: ми заздалегідь сказали, що title має бути саме таким. Тому це типова помилка входу.

Важливий практичний момент: помилки входу живуть на межі API. Тобто ви не повинні розмазувати їх по сервісах на кшталт if(request.getTitle() == null) throw .... Краще, щоб сервіс отримував вхід, який уже пройшов базові перевірки форми.

У контролері зазвичай достатньо одного @Valid:

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@PostMapping("/api/v1/tasks")
public TaskDetailsResponse create(@Valid @RequestBody TaskCreateRequest request) {
    // @RequestBody: кажемо Spring прочитати JSON із тіла і сконвертувати в DTO
    // @Valid: запускаємо Bean Validation до входу в бізнес-логіку
    return taskService.create(request); // до сервісу потрапляє вже «формально валідний» запит
}

Тепер про другу половину: помилки формату і перетворення типів. Тут дуже легко заплутатися, тому що вони не схожі на ваші constraints. Наприклад, клієнт викликає endpoint для списку з фільтром:

GET /api/v1/tasks?status=DOING

Якщо DOING не існує у вашому TaskStatus, то проблема не в бізнес-логіці. Сервер просто не розуміє, що йому передали, тому що значення не вкладається в допустимий набір. Це та сама природа помилки, що й @Size(min=3), просто в іншому місці пайплайна.

Ще один частий приклад — дати. Якщо ви чекаєте dueBefore=2026-03-01, а клієнт надіслав dueBefore=01-03-2026 (або dueBefore=вчора — так, люди вміють), то проблема у форматі. Сервер не може коректно зв’язати рядок із query-параметра з LocalDate. Це знову помилка входу.

І окрема «класика жанру» — malformed JSON. Наприклад, клієнт надіслав:

{ "title": "Pay rent", }

Кома перед закривною фігурною дужкою робить JSON некоректним, і обробка обірветься ще до валідації. Це важливо: не треба намагатися «спіймати» це в сервісі. Ви фізично не отримаєте DTO — його не вдалося створити.

У підсумку думка така: помилки входу — це все, що означає «запит не можна прийняти в поточному вигляді, тому що він порушує контракт кінцевої точки». Вони можуть статися в обмеженнях DTO, у параметрах URL, у JSON-парсингу, у перетворенні типів. Але зміст для API один: вхід некоректний.

4. Предметні помилки: коректний вхід, але не можна

Предметна (business) помилка — це той момент, коли запит виглядає пристойно: JSON читається, DTO створюється, валідація проходить, параметри конвертуються. Клієнт, якщо чесно, може навіть думати: «Ну все, зараз сервер зобов’язаний це зробити». А сервер відповідає: «Ні, не можна, тому що так влаштовані правила предметної області або поточний стан ресурсу».

Тут особливо важливо не скотитися в «усе — помилка валідації». У предметної помилки інший зміст. Це не «поле занадто коротке», а «операція конфліктує з правилами». Саме тому в попередніх лекціях ми й вводили межу між перевіркою входу та бізнес-перевіркою: бізнес-правила зазвичай залежать від стану системи, а отже їх не можна повністю виразити анотаціями на request DTO.

Класичний приклад — «ресурс не знайдено». Припустімо, у нас є endpoint:

GET /api/v1/tasks/{taskId}

Запит може бути ідеально сформований, taskId може бути рядком у правильному форматі (і навіть валідним UUID), але самої задачі в сховищі немає. Це не помилка валідації, тому що форма запиту коректна. Це предметна ситуація: клієнт адресує ресурс, якого немає в поточному стані системи.

У коді така ситуація має бути названа явно, а не повертатися як null. Наприклад:

public Task getById(String taskId) {
    // Шукаємо ресурс за ідентифікатором: сам taskId може бути «валідним», але даних може не бути.
    Task task = taskRepository.findById(taskId);

    if (task == null) {
        // Це доменна причина (ресурс відсутній), а не помилка формату входу.
        throw new TaskNotFoundException(taskId);
    }

    return task; // Тут гарантуємо викличному коду, що Task існує.
}

Тут з’являється важливий персонаж цієї моделі: іменований прикладний виняток. TaskNotFoundException говорить читачеві коду: «це не випадковий NPE і не абстрактне “щось пішло не так”, а конкретна причина — задачі немає». Корисно, коли такий виняток зберігає й taskId: цей контекст потім стане у пригоді для публічного error payload.

Інший приклад предметної помилки — «задача архівна, і її не можна змінювати». Це правило домену: вхід може бути коректним, але операція заборонена. Ми можемо виразити це так:

public void assertTaskNotArchived(Task task) {
    // Перевіряємо правило домену: «архівна задача незмінна».
    // Це не про форму запиту, а про стан ресурсу.
    if (task.getStatus() == TaskStatus.ARCHIVED) {
        throw new TaskArchivedException(task.getId());
    }
}

І знову: запит може бути ідеальним, title — красивим, теги — унікальними, але правило каже: «архівну задачу не редагуємо». Це не помилка форми запиту, а конфлікт операції зі станом ресурсу.

Зверніть увагу на важливий архітектурний момент: такі перевірки зазвичай живуть у сервісному шарі (або в доменному шарі), а не в контролері. Контролер має бути тонким: він приймає контракт і делегує виконання сценарію. Якщо ви почнете писати предметні правила в кожному контролері, дуже швидко отримаєте дві неприємності: правила почнуть дублюватися, а поведінка стане несумісною між endpointʼами.

Ще один типовий business-конфлікт — заборонений перехід статусу. Наприклад, якщо домен каже, що з DONE не можна повернутися в TODO, то запит «зміни статус» може бути коректним, але дія заборонена. У коді це перетворюється на осмислену причину:

public void changeStatus(Task task, TaskStatus newStatus) {
    // Приклад доменного обмеження на переходи статусів.
    // Вхід може бути коректним (валідний enum), але операція заборонена правилами.
    if (task.getStatus() == TaskStatus.DONE && newStatus == TaskStatus.TODO) {
        throw new InvalidStatusTransitionException(task.getId(), task.getStatus(), newStatus);
    }

    task.setStatus(newStatus); // Якщо правило не порушено — змінюємо стан.
}

Так, цей сценарій у нас буде розвиватися далі в проєкті, але як приклад класифікації він ідеальний: вхід коректний, а правило забороняє.

Загалом предметні помилки — це «не ламайте контракт запиту, але й не обіцяйте клієнту, що він може робити все, що йому заманеться». API — це не лише форма, а й зміст операцій.

5. Технічні збої на боці сервера

Технічний збій — це випадок, коли за змістом усе мало б спрацювати, але сервер не зміг коректно завершити сценарій через свою внутрішню проблему. Іноді це баг у коді (і так, вони бувають навіть у людей, які «точно все перевірили»). Іноді це проблема інфраструктури: неочікувана помилка файлової системи, брак пам’яті, збій серіалізації, внутрішня невідповідність даних.

Тут корисно тримати в голові просту думку: якщо клієнт нічого не може виправити в запиті, тому що запит нормальний, і при цьому помилка не описує предметне правило, то це майже напевно технічна проблема сервера.

Найзрозуміліший навчальний приклад — порушення внутрішніх інваріантів. Наприклад, ви очікуєте, що після збереження задача завжди має id. У нашому проєкті id генерується на сервері, клієнт ним не керує. Якщо з якоїсь причини id не проставився, це проблема сервера.

public TaskDetailsResponse create(TaskCreateRequest request) {
    // Зберігаємо нову задачу. Клієнт не керує id — це відповідальність сервера/сховища.
    Task saved = taskRepository.save(mapper.toModel(request));

    if (saved.getId() == null) {
        // Внутрішній інваріант зламано: після save() id обов’язково має існувати.
        // Це не помилка клієнта і не правило домену, а технічний збій.
        throw new IllegalStateException("Збережена задача не має id");
    }

    return mapper.toDetails(saved); // Перетворюємо модель у DTO відповіді.
}

Зверніть увагу: це не «предметне правило». Домен не каже: «задача інколи може бути без id». Це просто зламалася наша внутрішня логіка. Тому ми й кидаємо IllegalStateException: ми як розробники заявляємо, що такий стан неприпустимий.

Інший приклад — випадковий null, який не повинен був бути null. Наприклад, mapper отримав Task і раптом усередині Task поле title виявилося null, хоча система мала це гарантувати. Залежно від того, де гарантія мала спрацювати, це може бути або баг (technical), або діра у validation (тоді ви неправильно класифікували й пропустили помилку входу). Але якщо ви вже впевнені, що title обов’язковий, а всередині моделі він null, це майже напевно технічна проблема.

Технічні збої особливо важливо відрізняти від предметних помилок з однієї причини: вони не повинні перетворюватися на «нормальну гілку поведінки». Якщо ви починаєте писати код на кшталт «якщо стався NullPointerException — поверни клієнту JSON “щось пішло не так” і живи далі», ви непомітно робите систему крихкою і неперевірюваною. Технічний збій — це сигнал: «виправити на сервері».

І ще один момент: технічні збої не повинні «маскуватися» під валідацію або предметну помилку. Іноді дуже хочеться сказати клієнту: «Ну, це ви щось не те надіслали», тому що так простіше. Але якщо правда в тому, що сервер упав через баг, треба чесно вважати це технічним збоєм. У наступній лекції ми говоритимемо про статуси, але зміст уже зараз такий: різні категорії помилок мають мати різні семантичні відповіді, інакше клієнт починає жити в режимі вгадування.

6. Прикордонні випадки: швидкий алгоритм

На практиці помилки не приходять із табличкою «я validation» або «я business». Вони приходять як факти: запит не оброблено, десь кинувся виняток, якийсь крок пайплайна зламався. Тому корисно мати невеликий алгоритм класифікації, який можна прогнати в голові, не відкриваючи філософський трактат.

Найпрактичніший спосіб — ставити собі запитання у правильному порядку. Якщо дуже спростити, спочатку ми перевіряємо: «Запит взагалі можна прочитати і пов’язати з контрактом?». Потім — «Чи можна виконати операцію за правилами домену?». І лише після цього — «Чи не сталося чогось неочікуваного на сервері?».

Нижче — схема, яку зручно тримати в голові (і, так, її можна намалювати на стікері; ніхто не дізнається, крім вашого кота).

flowchart TD
    A[Запит прийшов] --> B{"Можна прочитати і пов’язати з контрактом?"}
    B -->|"ні: JSON / типи / обов'язкові поля"| V[Помилка входу]
    B -->|так| C{"Операцію дозволено правилами домену та станом ресурсу?"}
    C -->|ні| D[Предметна помилка]
    C -->|так| E{"Сервер коректно виконав сценарій?"}
    E -->|"ні: баг / збій"| T[Технічна помилка]
    E -->|так| OK[Успіх]

Тепер розберемо найчастіші «пастки»:

З malformed JSON усе просто: якщо тіло запиту не розпарсилося, це не business і не technical. Це помилка входу, тобто validation за змістом. Тут клієнт зобов’язаний виправити запит, інакше сервер фізично не зможе зрозуміти дані.

З відсутністю ресурсу часто плутаються. Здається, що «ну раз taskId неправильний — значить validation». Але ні: taskId може бути цілком коректним ідентифікатором, просто ресурсу немає. Це помилка про стан системи. Вона не про форму запиту, а про відсутність даних.

Із заборонами на кшталт «архівну задачу не можна змінювати» теж часто плутаються, особливо якщо новачок звик до думки «усе перевіряємо анотаціями». Але це правило не про структуру входу, а про зміст операції в поточному стані. Отже, це предметна помилка.

А технічний збій — це те, що не вкладається ані в «вхід поганий», ані в «правило домену забороняє». Якщо у вас раптом IllegalStateException, яке означає «усередині системи неможливий стан», це не привід звинувачувати клієнта. Це привід виправляти код.

Корисна «шпаргалка» за ознаками (без перетворення її на списки) виглядає так: якщо помилка вказує на конкретне поле запиту та його допустимість, це майже завжди validation. Якщо помилка говорить про конкретний ресурс, його існування та стан, це найчастіше business. Якщо помилка каже «неможливий стан», unexpected, null там, де не повинно бути null, IO error — це технічне.

Коли така класифікація зібрана, вибір статусу перестає бути ворожінням: у 400, 404, 409 і 500 нарешті з’являється опора в змісті, а не в настрої автора контролера.

7. Іменовані винятки і категорії в коді

Дуже часта проблема початкових проєктів — винятки без змісту. Наприклад, throw new RuntimeException("Bad request"). Такий рядок у коді нічого не пояснює: не зрозуміло, це про валідацію, про предметне правило, про відсутність ресурсу чи про внутрішній баг. У підсумку категорія помилки губиться, а далі губиться і правильний HTTP-статус, і нормальна форма відповіді.

Іменовані прикладні винятки вирішують цю проблему: вони перетворюють «щось пішло не так» на конкретну причину, яку можна послідовно обробляти. У межах нашого курсу вони живуть у пакеті com.example.tasktracker.domain.exception, тому що здебільшого виражають доменні причини (not found, archived, conflict). Це важливий поділ: DTO і validation — в api, а змістові причини — в domain.

Приклад задача не знайдена у нас уже виглядає як іменований прикладний виняток із taskId всередині. Тут важлива сама категорія: це доменна причина ресурс відсутній.

Усередині такого винятку повідомлення може поки що бути «сируватим». І це нормально. Зараз нам важливо інше: причина отримала ім’я, і в коді ми не сплутаємо її з «архівною задачею» або «технічним збоєм».

А ось приклад винятку «задача архівована»:

package com.example.tasktracker.domain.exception;

// Доменна причина: операція заборонена через стан ресурсу (ARCHIVED).
public class TaskArchivedException extends RuntimeException {
    public TaskArchivedException(String taskId) {
        // Повідомлення — внутрішнє; пізніше будемо мапити причини на єдиний формат API-помилок.
        super("Задача архівована: " + taskId);
    }
}

Тепер TaskNotFoundException і TaskArchivedException — обидва про домен, але змісти різні. І це вже допомагає класифікувати: not found — відсутність ресурсу, archived — заборона операції через стан ресурсу.

Є два важливі правила, які допомагають не перетворити винятки на зоопарк.

Перше правило: не треба заводити окремий виняток на кожен рядок коду. Ми не пишемо енциклопедію винятків, ми фіксуємо стійкі причини, які реально важливі для зовнішнього контракту API. Task not found — важлива причина. Mapper got null task — найчастіше внутрішній баг і може залишитися технічним винятком.

Друге правило: виняток має виражати зміст, а не стратегію відповіді. Тобто TaskNotFoundException — хороша назва, а Task404Exception — погана. Чому? Тому що ми ще навіть не дійшли до мапінгу статусів, а вже протягнули HTTP у доменний шар. Доменні причини мають залишатися доменними причинами; HTTP — це транспортний шар, який ми накладемо поверх.

І так, валідаційні помилки, які генерує Bean Validation або Spring MVC binding, ми зазвичай не оформлюємо власними винятками. Вони й так «іменовані» на рівні фреймворка. Наше завдання — зрозуміти їхню категорію і пізніше привести до єдиної моделі відповіді, але не плодити ручну обробку в контролерах.

8. Типові помилки під час класифікації помилок

Помилка №1: вважати, що «будь-яка помилка = валідація».
Це виглядає спокусливо: повісили анотації, а все інше назвали «invalid request». Але в підсумку клієнт не відрізняє «у вас порожній title» від «задачі не існує» і від «архівну задачу не можна змінювати». API стає нерозбірливим: наче все 4xx, а змісту — нуль.

Помилка №2: називати відсутність ресурсу помилкою валідації.
Якщо запит коректний, але дані в системі відсутні, це не «поганий формат», а стан домену. Ви не можете виправити це, «трохи змінивши JSON». Клієнт має розуміти, що адресованого ресурсу немає, а не те, що він «не пройшов constraints».

Помилка №3: ховати предметні конфлікти в технічні помилки.
Коли правило домену забороняє операцію, а ви замість цього кидаєте загальний RuntimeException і отримуєте «серверну помилку», клієнт думатиме, що «сервер зламаний», хоча насправді він просто просив виконати заборонену дію. Це ламає UX і заважає налагоджувати сценарії.

Помилка №4: видавати внутрішній баг за проблему клієнта.
Іноді хочеться «заспокоїти» систему і повернути «некоректний запит», коли насправді у вас NullPointerException через баг. Так ви позбавляєте себе шансу нормально виправити проблему: у логах буде хаос, клієнт намагатиметься вгадати, як «правильно надіслати запит», а сервер і далі падатиме.

Помилка №5: використовувати один спільний виняток BusinessException для всього.
Наче ви вже зробили крок уперед — відокремили business від technical. Але якщо всередині business-корзини лежить і not found, і archived, і invalid status transition, і comment not found, ви знову втрачаєте зміст. Іменовані винятки потрібні саме для того, щоб зміст був розрізнюваним.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ