1. try/catch у контролері: зручність і пастка
Коли ви лише починаєте писати REST API на Spring MVC, ідея «оброблю помилку просто тут, у методі контролера» виглядає дуже природно. Це як тримати аптечку поруч із робочим столом: сталося щось неприємне — швидко відреагували, повернули гарний 404, сформували ProblemDetail, клієнт задоволений. Але в API це працює рівно до того моменту, доки у вас не стане більше ніж один ендпойнт і більше ніж один вид помилок.
Проблема в тому, що контролер за своєю природою — це межа: він уже виконує багато «транспортних» речей (path/query/body binding, статус, заголовки, response body). Якщо додати сюди ще й повноцінне формування помилок, контролер перетворюється на універсальний «комбайн», який і запити приймає, і бізнес-сенс розуміє, і формат помилок ліпить, і статус вибирає. У результаті код розростається, читабельність падає, а єдиний контракт помилок починає розповзатися по проєкту.
Подивімося на типовий «перший робочий варіант», який майже завжди з’являється в навчальних проєктах. Він не поганий «бо так не можна», він поганий тому, що не масштабується.
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}")
public ResponseEntity<?> getTask(@PathVariable String taskId) {
try {
// Успішний шлях: просто повертаємо дані задачі
return ResponseEntity.ok(taskService.getById(taskId));
} catch (TaskNotFoundException ex) {
// Шлях помилки: контролер сам вирішує, який статус і яке тіло помилки повернути
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
// Вручну заповнюємо поля контракту помилки (і так доведеться робити в кожному ендпойнті)
problem.setTitle("Task not found");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}
}
Код «правильний» у тому сенсі, що він справді поверне 404 і справді поверне ProblemDetail. Але зверніть увагу: ми вже зробили контролер відповідальним за дві задачі одночасно — успішний шлях і шлях помилки. Поки це один метод, таке ще можна терпіти. А тепер уявіть, що таких методів стане двадцять, і в кожного будуть свої нюанси.
2. «Товстий» контролер: змішування відповідальності
Контролер у нашому курсі задуманий як тонкий шар. Він описує HTTP-контракт: «який URL», «який метод», «який вхід», «який вихід». Він має нагадувати перекладача, який акуратно перекладає з мови HTTP на мову застосунку — через виклик сервісу — і назад. Щойно контролер починає «лікувати» всі помилки, він перестає бути перекладачем і стає ще й лікарем, ще й суддею, ще й нотаріусом, який засвідчує правильність кожного статусу.
Змішування відповідальності виглядає так: ви хочете просто прочитати метод контролера й зрозуміти, що він робить. Але замість цього читаєте пів сторінки обробки винятків. Це особливо боляче в ендпойнтах на кшталт DELETE, де успішний шлях займає дві стрічки, а catch — десять. У якийсь момент ви починаєте думати: «Ну гаразд, я скопіюю цей catch», — і саме так народжується копіпаста.
Порівняймо два варіанти. Перший — контролер як «комбайн»:
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
@DeleteMapping("/api/v1/tasks/{taskId}")
public ResponseEntity<?> delete(@PathVariable String taskId) {
try {
// Успішний шлях: видаляємо і повертаємо 204
taskService.delete(taskId);
return ResponseEntity.noContent().build();
} catch (TaskNotFoundException ex) {
// Шлях помилки: знову вручну збираємо ProblemDetail
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Task not found");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd);
}
}
І другий — контролер як «тонка межа» (поки без обговорення як саме буде оброблено виняток, це наступна лекція):
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseStatus;
import static org.springframework.http.HttpStatus.NO_CONTENT;
@DeleteMapping("/api/v1/tasks/{taskId}")
@ResponseStatus(NO_CONTENT) // Контракт успішної відповіді: 204 без тіла
public void delete(@PathVariable String taskId) {
// Контролер не збирає помилки: просто делегує в сервіс
taskService.delete(taskId);
}
У другому варіанті ми одразу бачимо сенс ендпойнта: видалити задачу і повернути 204. Помилка «задачу не знайдено» тут не описана, але це нормально: контролер не зобов’язаний бути енциклопедією всіх лих. Його задача — «що робимо, коли все добре». А «що робимо, коли все погано» має вирішуватися єдиним способом, інакше у нас не вийде єдиний контракт помилок.
І так, у реальності в початківців частіше виходить так: частина методів «повертає void із @ResponseStatus», частина використовує ResponseEntity, частина ловить винятки, частина не ловить. У результаті проєкт живе за принципом «як вийшло» — і це дуже швидко стає проблемою.
3. Копіпаста в try/catch: ріст разом з ендпойнтами
Спочатку у вас один ендпойнт, і ви чесно написали catch (TaskNotFoundException ex). Потім додали другий ендпойнт і зробили «скопіювати-вставити, але змінити шлях». Потім третій. Потім раптом вам потрібно змінити текст title або додати нове поле в ProblemDetail, або змінити detail, або уніфікувати формулювання. І тут ви виявляєте, що у вас таких catch-блоків уже десять.
Найнеприємніше в тому, що копіпаста в error handling майже завжди виглядає «логічно» в момент написання. Ви не відчуваєте, що зробили щось погане, бо код компілюється, тест у Postman зелений, а клієнт отримує JSON. Але з архітектурного погляду ви підписалися на довічну розстрочку: будь-яке покращення помилки тепер вимагає пройтися по купі місць.
Уявімо, що ви хочете зробити єдиний стиль, наприклад завжди писати title="Resource not found", а в detail додавати конкретику. Якщо помилки формуються прямо в контролері, ви будете вистежувати ці рядки по проєкту. А якщо завтра з’явиться не лише TaskNotFoundException, а ще CommentNotFoundException і AttachmentNotFoundException, то почнеться ще веселіша гра: «додай catch усюди».
Щоб відчути проблему, достатньо побачити, що один і той самий патерн повторюється:
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
private ResponseEntity<ProblemDetail> notFound(String detail) {
// Локальний допоміжний метод — перший сигнал, що логіка помилки повторюється і проситься в одне місце
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, detail);
pd.setTitle("Not found"); // Ще одне місце, де легко «випадково» змінити формулювання
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd);
}
Цей допоміжний метод уже натякає: «схоже, це має бути десь в одному місці». І це правда. Але якщо ви тримаєте все в контролерах, то або почнете плодити такі допоміжні методи в кожному контролері, або почнете робити спільний утилітний клас, який раптово стане залежати від HTTP і почне використовуватися «скрізь», включно із сервісами. А це вже наступний рівень архітектурного квесту.
4. Як розповзається error contract: одна помилка — різні відповіді
Єдиний контракт помилок — це договір між вашим API і клієнтом. Клієнт хоче розуміти: якщо задачу не знайдено, то це завжди 404, завжди application/problem+json, завжди визначений набір полів, і бажано стабільні title/detail (або хоча б стабільний application-specific code, який ми будемо розвивати пізніше, за планом курсу).
Локальний try/catch цей договір руйнує не тому, що анотації «крутіші», а тому, що люди різні. Ви (або ваш колега) в одному контролері напишете так:
{
"title": "Task not found",
"status": 404,
"detail": "Task '123' not found"
}
А в іншому так:
{
"title": "Not Found",
"status": 404,
"detail": "No task"
}
Формально обидві відповіді «правильні»: і там 404, і там JSON, і там щось написано. Але для клієнта це вже два різні світи. Якщо фронтенд хоче показати користувачу гарне повідомлення, він починає гадати, який title прийде. Якщо інший сервіс хоче обробити помилку автоматично, він змушений парсити текст (а це майже завжди погана ідея, бо текст — для людей, а не для коду).
Найпідступніше тут те, що розсинхрон з’являється поступово. Сьогодні ви зробили Task not found. Завтра хтось зробив Not Found. Післязавтра ви вирішили, що Task not found — надто вузько, і зробили Resource not found. І ось у вас уже три варіанти одного сенсу.
Локальні try/catch по контролерах майже гарантовано призводять до того, що єдиний контракт житиме «в голові», а не в коді. А в програмуванні те, що живе «в голові», має звичку зникати за першої ж відпустки або дедлайну.
5. try/catch не ловить помилки до контролера
Є ще одна причина, чому «ловити все в контролері» — технічно неробоча стратегія, навіть якщо ви дуже дисциплінована людина і обіцяєте собі «я писатиму однаково». У Spring MVC значна частина помилок відбувається раніше, ніж керування потрапить у ваш метод.
Наприклад, некоректний JSON. Клієнт надіслав зламаний JSON, або рядок там, де очікується число, або неправильний формат дати. Усе це відбувається на етапі читання request body через конвертери. І якщо помилка сталася там, до вашого методу контролера справа просто не дійде.
Сценарій виглядає приблизно так:
sequenceDiagram
participant Client as HTTP-клієнт
participant MVC as "Spring MVC (DispatcherServlet)"
participant Conv as "Перетворення тіла (Jackson)"
participant Ctrl as Метод контролера
Client->>MVC: POST /api/v1/tasks + JSON-тіло
MVC->>Conv: прочитати і розпарсити body
Conv-->>MVC: "помилка (наприклад, JSON некоректний)"
Note over Ctrl: метод контролера не викликається
MVC-->>Client: 400 (помилка на рівні web-шару)
Тобто ви можете написати хоч три catch у контролері — вони не спрацюють, бо метод просто не почався.
Це дуже важливо для нашого курсу, бо ми вже обговорювали: некоректний JSON — не бізнес-помилка, не «не знайдено» і навіть не валідація у звичному сенсі. Це проблема читання запиту. І якщо у вас архітектура «помилки ловимо в контролерах», то ви все одно зіткнетеся із ситуаціями, де вам потрібен механізм на рівень вище, ніж окремий метод.
Spring MVC якраз підтримує ProblemDetails (application/problem+json) на рівні фреймворку та вмикає це налаштуванням spring.mvc.problemdetails.enabled=true. Це ще один сигнал: обробка помилок — це не приватна проблема одного ендпойнта, а наскрізна задача всього web-рівня.
6. Архітектурна мета: успішний сценарій у контролерах
Якщо описати бажану картину простими словами, то ми хочемо досягти такого ефекту: відкриваєте будь-який контролер — і там читається, що робить ендпойнт. А якщо хочете зрозуміти, як API відповідає на помилки, — ідете в одне місце й бачите правила.
Це не тому, що ми фанати «все централізувати». Це тому, що помилки — це теж контракт. І якщо контракт розмазаний по 30 методах, він перестає бути контрактом і стає набором місцевих звичаїв.
Давайте зафіксуємо ролі у вигляді маленької таблиці. Вона не про «єдину правильну архітектуру на всі часи», а про дисципліну саме нашого курсу і нашого проєкту Task Tracker API.
| Шар | Що робить, коли все добре | Що робить, коли стається помилка |
|---|---|---|
| Controller | Приймає HTTP-вхід, викликає сервіс, повертає DTO/статус | Не формує JSON помилки вручну |
| Service | Виконує прикладну логіку | Повідомляє про проблему через виняток |
| Error handling layer | Не бере участі в успішному сценарії | Перекладає виняток у ProblemDetail і HTTP-відповідь |
У такій схемі сервісний шар залишається чистим: він не знає про ResponseEntity, не знає про HTTP-статуси й не перетворюється на «напівконтролер». А контролери залишаються короткими й передбачуваними.
Саме тому локальний try/catch у контролері — погана ідея: він не просто дублює код. Він ламає межу шарів і заважає зробити один стабільний, єдиний контракт помилок.
7. Рефакторинг: прибираємо try/catch
Щоб це не залишилося розмовою «в теорії так краще», давайте зробимо дуже маленький, майже косметичний, але принциповий крок у стилі коду.
Було (локальний try/catch, контролер знає, як улаштований ProblemDetail):
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}")
public ResponseEntity<?> getById(@PathVariable String taskId) {
try {
// Контролер повертає успішну відповідь
return ResponseEntity.ok(taskService.getById(taskId));
} catch (TaskNotFoundException ex) {
// Контролер сам формує контракт помилок (і робитиме це в багатьох місцях)
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Task not found");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd);
}
}
Стало (контролер описує контракт успішної відповіді):
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}")
public TaskDetailsResponse getById(@PathVariable String taskId) {
// Контролер максимально «тонкий»: лише HTTP-вхід і делегування в сервіс
return taskService.getById(taskId);
}
І тепер сервіс, якщо задачу не знайдено, просто викидає доменний виняток (це нормальний спосіб повідомити нагору, що сценарій не може продовжуватися):
public TaskDetailsResponse getById(String taskId) {
return repository.findById(taskId)
// Успішний шлях: знайшли задачу — мапимо в DTO
.map(taskMapper::toDetailsResponse)
// Шлях помилки: не знайшли — піднімаємо доменну помилку нагору
.orElseThrow(() -> new TaskNotFoundException(taskId));
}
Важливий момент: ми поки не обговорюємо, яким саме способом цей виняток перетвориться на 404 і ProblemDetail. Це спеціально відкладено на наступні лекції дня, тому що там ми будемо будувати єдиний шар обробки помилок і обговорювати механізми Spring MVC для цього. Сьогодні нам важливо відчути архітектурну ідею: контролери не мають жити в режимі «на кожен ендпойнт — свій мініцентр обробки катастроф».
8. Типові помилки при try/catch у контролерах
Помилка №1: ловити Exception і повертати «щось» (зазвичай 500) прямо в контролері.
Таке рішення здається зручним: «ну якщо щось зламалося — повернемо internal error». Але ви втрачаєте точність поведінки API. По-перше, деякі помилки мають бути 400 або 404, а ви перетворюєте їх на 500, і клієнт починає думати, що сервер “впав”, хоча він просто отримав поганий вхід. По-друге, щойно цей патерн з’являється в одному місці, він починає копіюватися, і проєкт перетворюється на набір ендпойнтів із різними «універсальними» обробниками.
Помилка №2: копіювати збірку ProblemDetail по проєкту і сподіватися “я всюди однаково вставлю”.
На практиці однаково не виходить. Десь забудуть виставити title, десь поставлять інший текст, десь змінять detail, десь повернуть ResponseEntity<?>, а десь — ResponseEntity<ProblemDetail>. У результаті клієнту доводиться або миритися з цим, або писати костилі. Найнеприємніше, що зовні це виглядає як «дрібні відмінності», але саме вони й ламають контракт.
Помилка №3: поступово протягнути HTTP-рішення в сервісний шар.
Коли контролери обробляють помилки вручну, часто виникає спокуса: а давайте сервіс одразу скаже, який статус повертати. Це призводить до того, що сервісний шар починає залежати від HttpStatus, ResponseEntity та іншого web-type. Після цього тестувати сервіс як бізнес-логіку стає складніше, а архітектура починає нагадувати бутерброд із шарів, де кожен шар знає про сусідів усе.
Помилка №4: вважати, що try/catch у методі контролера — це “глобальна обробка помилок”.
Ми вже обговорили, що частина помилок відбувається до входу в метод контролера: некоректний JSON, проблеми десеріалізації, відсутні параметри, type mismatch. Локальний try/catch просто фізично не може їх упіймати. У результаті у вас усе одно з’являється другий механізм обробки помилок десь вище, і API починає відповідати різними форматами залежно від того, «встигла» помилка добігти до методу чи ні.
Помилка №5: робити обробку помилок частиною основної логіки ендпойнта.
Іноді в catch починають не лише формувати відповідь, а й «доробляти» бізнес-операцію: щось відкочувати, щось дописувати, змінювати стан. Це перетворює обробку помилок на бізнес-логіку, тільки сховану в несподіваний кут. Такий код майже неможливо підтримувати: при зміні сценарію ви не пам’ятаєте, що «ще в catch ми робили ось це».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ