1. 404 недостатньо: помилка теж є контрактом
Коли ви створюєте API, ви фактично підписуєте договір із клієнтом: «якщо ви надішлете ось такий запит — я відповім ось так». І цей договір має бути однаково зрозумілим і в успішному сценарії, і в ситуації помилки. Якщо сервер обмежується формулою «ну ось вам 404, а далі здогадуйтеся самі», клієнту доводиться вгадувати: це «не знайдено», «звернулися не туди», «неправильний формат» чи «сервер просто втомився».
У реальному житті клієнтський код рідко читає «Not Found» як людина з браузером і сумує над долею. Частіше це мобільний застосунок, фронтенд, інтеграція, тести або моніторинг. Їм потрібен не художній опис страждань, а структурована відповідь: що сталося, де саме і що з цим робити. Тож наше завдання — побудувати мінімальний, але самодостатній каркас помилки, який завжди матиме однакову форму.
І тут важливо зафіксувати просту думку: HTTP-статус — лише вершина айсберга. Він говорить про клас проблеми, але не про конкретику. А помилка без конкретики — це як баг-репорт «не працює» без кроків відтворення. Формально не брешете, але користі майже немає.
2. ProblemDetail у Spring MVC
ProblemDetail — це стандартна ідея: описувати помилку не одним текстом, а невеликим JSON-обʼєктом із фіксованими полями. У Spring MVC цю ідею втілено в класі ProblemDetail. Ми не вигадуємо власний формат помилок із полем message і success=false, а використовуємо зрозумілий каркас, який багато клієнтів уже вміють обробляти — або принаймні їх легко цьому навчити.
Якщо уявити обробку запиту як конвеєр, то при помилці він виглядає приблизно так: контролер або сервіс кидає виняток, @ControllerAdvice його ловить, перетворює на ProblemDetail, а далі Spring MVC серіалізує цей обʼєкт у JSON і виставляє Content-Type: application/problem+json. Жодної магії — лише нормальна, доросла дисципліна обробки негативних сценаріїв.
flowchart TD
A[HTTP-запит] --> B[Контролер]
B --> C[Сервіс]
C --> D{Помилка?}
D -->|ні| E[DTO відповіді]
D -->|так| F[Виняток]
F --> G["@ControllerAdvice / @ExceptionHandler"]
G --> H[ProblemDetail]
H --> I[HTTP-відповідь: application/problem+json]
Важливий нюанс для проєкту: Spring Boot уміє працювати з Problem Details «з коробки», але ви маєте бути послідовними. Якщо частину помилок ви повертаєте як ProblemDetail, а частину — як випадковий Map, клієнту буде боляче. Йому доведеться писати розгалуження за форматом відповіді, і це вже не REST API, а квест «вгадай JSON».
Ще одна практична деталь: навіть якщо клієнт і так знає, який URL він викликав, поле instance у відповіді робить помилку самодостатньою. Це особливо помітно, коли відповіді логуються, летять у трасування, тести падають на CI, і ви дивитеся на логи без повного контексту. Помилка, яка несе в собі мінімальні дані про запит, економить час на розслідування.
3. Мінімальний каркас ProblemDetail
Зараз ми зафіксуємо те, що має бути в кожному error response, якщо ми хочемо називати API production-like хоча б за манерами. У ProblemDetail є стандартні поля, і саме вони — наш мінімальний «скелет». Усе додаткове (code, fieldErrors, timestamp, requestId) — це вже надбудови, які ми будемо акуратно додавати пізніше, не руйнуючи базову форму.
Нижче — коротка «шпаргалка» по пʼяти полях. Не намагайтеся вивчити її як таблицю множення; краще сприймайте як відповіді на пʼять запитань клієнта: «що це?», «як це назвати?», «який статус?», «що конкретно сталося?» і «до якого запиту це стосується?».
| Поле | Запитання, на яке відповідає | Що важливо памʼятати | Приклад значення |
|---|---|---|---|
| type | «Який це клас проблеми?» | Це ідентифікатор типу проблеми (URI), а не текст помилки | /problems/task-not-found |
| title | «Як це коротко назвати?» | Коротка й стабільна назва для людини | Задачу не знайдено |
| status | «Який HTTP-статус у помилки?» | У тілі дублюємо статус, щоб відповідь була самодостатньою | 404 |
| detail | «Що конкретно сталося в цьому запиті?» | Тут доречна конкретика: id, поточний стан, значення параметра | Задачу з id ... не знайдено |
| instance | «До якого запиту це стосується?» | URI запиту (або його path), щоб привʼязати помилку до конкретного виклику | /api/v1/tasks/... |
type: ідентифікатор класу проблеми
Поле type часто сприймають як «ну давайте туди покладемо щось». А потім туди кладуть текст повідомлення, і сенс стандарту ламається. Насправді type — це ідентифікатор типу проблеми, який має бути стабільним. Він не про конкретний випадок, а про категорію: «task not found», «malformed json», «unsupported media type».
Формально type — це URI (у стандарті допускається і відносний URI). В ідеальному світі це посилання на сторінку документації, де описано, що це за помилка і як її обробляти. У навчальному проєкті ми не будемо розгортати портал документації помилок — інакше курс перетвориться на “Spring + static hosting”, — але все одно можемо задати type як стабільний ідентифікатор, наприклад /problems/task-not-found.
Практичний критерій простий: якщо ви зміните текст detail — бо захотіли покращити формулювання, — type має лишитися тим самим. Це «імʼя папки», а detail — «імʼя файлу всередині папки». Якщо ви робите навпаки, ви вносите хаос.
Ще один важливий момент: не підміняйте type вашим application-specific code. type — це частина стандарту Problem Details і зазвичай виражає тип проблеми в термінах URI. code — це внутрішня, машиночитна константа застосунку. Ми до неї дійдемо далі, але зараз важливо не змішати ролі, щоб не вийшло два поля «про одне й те саме», але по-різному.
title: коротка назва проблеми
title — це не місце для роману. Це короткий підпис, який зручно показати людині або написати в лог, щоб одразу зрозуміти, про що йдеться. Він має бути достатньо стабільним: якщо сьогодні ви повертаєте title = "Задачу не знайдено", а завтра title = "Такої задачі немає", клієнту складно будувати будь-які користувацькі повідомлення або шукати по логах. Так, клієнт не має парсити title як код, але стабільність усе одно корисна.
Хороша практика така: title — це «заголовок категорії», а detail — «опис конкретного випадку». Тому title зазвичай не містить ID, поточного статусу чи параметрів запиту. Максимум — акуратне формулювання на кшталт «Task not found», «Invalid input», «Unsupported media type».
Є ще одна людська причина: title — це те, що ви побачите в Swagger UI, у Postman і в тестах. Якщо туди писати все підряд, через тиждень ви самі перестанете розуміти, де яка помилка. Стабільний title робить діагностику простішою. І так, це той рідкісний випадок, коли дисципліна — не нудьга, а економія часу.
status: статус і в HTTP, і в тілі
На перший погляд дублювання статусу виглядає дивно: «Але ж статус уже є в HTTP response!». Так, є. Але в контрактному мисленні це не зайві байти, а самодостатність. Помилки часто живуть довше, ніж одна конкретна HTTP-відповідь: їх логують, пересилають, зберігають у звітах, аналізують у тестах. І там не завжди зручно діставати статус із метаданих — інколи ви бачите лише JSON.
Крім того, наявність status у тілі допомагає швидко перевірити, що ваш @ControllerAdvice не помилився. Це як ремінь безпеки: наче й так обережно їздите, але добре, що він є. Якщо ви випадково повернули HTTP 404, а в тілі status: 500, ви одразу помітите неузгодженість. А неузгодженість статусу — це прямий шлях до недовіри клієнта: він уже не знає, чому вірити.
У Spring ProblemDetail.forStatusAndDetail(...) зазвичай виставляє статус автоматично. Це зручно, але не звільняє від відповідальності. Потрібно стежити, щоб реальний HTTP-статус відповіді збігався з тим, що ви серіалізуєте. Інакше ваш API починає говорити різними голосами одночасно — як чат, у якому один пише «все ок», а інший поруч кричить «ми всі загинемо».
detail: конкретика поточного випадку
detail — це місце, де ви описуєте конкретний інцидент. Якщо title — це «як називається проблема», то detail — «що саме сталося просто зараз». Саме сюди нормально включати taskId, значення неправильного параметра, поточний статус ресурсу, очікуваний формат тощо. Але тут важливо не перегнути палицю й не почати витікати внутрішніми подробицями.
Хороша формула для detail така: текст має бути зрозумілим зовнішньому клієнту, який не знає ваших Java-класів, пакетів і того, як називається ваш repository. Повідомлення "com.example.tasktracker.TaskServiceImpl threw NullPointerException" — це чудовий спосіб розповісти світові, що у вас відбувається всередині, і водночас чудовий спосіб зробити клієнта безпорадним: йому все одно незрозуміло, що робити.
Ще один нюанс: detail не має бути суперстабільним. Він може змінюватися від запиту до запиту, бо в ньому зʼявляються різні ID і різні значення. Це нормально. Стабільність ми забезпечимо пізніше через code, а зараз достатньо розуміти: detail — це корисна конкретика для людини і для швидкої діагностики.
instance: привʼязка до запиту
instance — це URI, який ідентифікує конкретний випадок проблеми. Найпростіше в REST API трактувати його як шлях запиту, наприклад /api/v1/tasks/{taskId}. Завдяки цьому клієнт і ви самі в логах бачите, до якої кінцевої точки (endpoint) належить помилка. І це допомагає навіть тоді, коли ви помилково надіслали запит не туди або коли один і той самий code/title може виникати в різних місцях.
У нашому навчальному проєкті instance часто закриває потребу в окремому полі path. Так, можна додати path як extension field, але це зайве дублювання, якщо instance уже показує URI запиту. Чим менше дублювальних полів, тим простіше підтримувати контракт: менше місць, де можна випадково розʼїхатися.
Практична порада: instance має бути максимально близьким до реального URI запиту. Якщо ви вручну збираєте рядок і помилилися, наприклад забули /api/v1 або переплутали taskId, ви отримаєте «помилку про помилку» — у відповіді буде неправильне посилання на запит. Тому в реальному коді часто беруть URI із запиту, наприклад через HttpServletRequest. Ми поки покажемо спрощений, навчальний варіант — вручну, але з правильною ідеєю.
4. Приклади та обмеження в Task Tracker API
Мінімальна реалізація: TaskNotFoundException → ProblemDetail
Зараз зберемо мінімальний приклад на нашому проєкті Task Tracker API, не лізучи в додаткові поля і не ускладнюючи модель. У нас уже є глобальний шар обробки помилок на базі @ControllerAdvice. Ми додамо або уточнимо обробник TaskNotFoundException, який повертає ProblemDetail із заповненими type, title, status, detail і instance.
Нижче — саме мінімальний базовий набір: лише стандартні поля ProblemDetail. Зверху на нього можна навісити code, fieldErrors і діагностичні поля, але сам каркас type/title/status/detail/instance звідси вже не змінюється.
Почнемо з доменного винятку. Він живе в com.example.tasktracker.domain.exception і має вміти повідомити taskId, щоб @ControllerAdvice міг зібрати зрозумілий для людини detail. Зверніть увагу: ми зберігаємо taskId окремим полем, а не намагаємося парсити його з тексту повідомлення — парсити рядки завжди боляче.
public class TaskNotFoundException extends RuntimeException {
// Ідентифікатор задачі, яку не знайшли, — потрібен для формування ProblemDetail.detail
private final String taskId;
public TaskNotFoundException(String taskId) {
// Зберігаємо id окремим полем, щоб не парсити його з тексту повідомлення
this.taskId = taskId;
}
public String getTaskId() {
// Повертаємо id, щоб обробник помилок міг зібрати зрозумілий опис
return taskId;
}
}
Тепер обробник у нашому GlobalExceptionHandler — він у нас на web-рівні, наприклад у пакеті com.example.tasktracker.api.error. Тут ми створюємо ProblemDetail і заповнюємо поля. status і detail виставляються через forStatusAndDetail, а решту — вручну, щоб каркас був повністю визначений.
@ExceptionHandler(TaskNotFoundException.class)
public ProblemDetail handleTaskNotFound(TaskNotFoundException ex) {
// Базу (status + detail) зручно виставляти через фабрику ProblemDetail
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
"Задачу з id " + ex.getTaskId() + " не знайдено"
);
// type — стабільний ідентифікатор типу проблеми
pd.setType(URI.create("/problems/task-not-found"));
// title — коротка, стабільна назва для людини
pd.setTitle("Задачу не знайдено");
// instance — привʼязка до конкретного запиту або ресурсу
pd.setInstance(URI.create("/api/v1/tasks/" + ex.getTaskId()));
return pd;
}
Якщо тепер клієнт викличе GET /api/v1/tasks/{taskId} з неіснуючим id, він отримає відповідь приблизно такої форми — спрощений приклад JSON. І це вже виглядає як договір: зрозуміло, що сталося, до якого запиту належить помилка, який статус і який тип проблеми.
{
"type": "/problems/task-not-found",
"title": "Задачу не знайдено",
"status": 404,
"detail": "Задачу з id 7f3c2e52-1a1b-4f03-9c3d-2c9d8a8a8e12 не знайдено",
"instance": "/api/v1/tasks/7f3c2e52-1a1b-4f03-9c3d-2c9d8a8a8e12"
}
І ще один маленький, але важливий штрих: клієнт має бачити, що це саме problem details. Тобто Content-Type відповіді буде application/problem+json. Spring MVC уміє це робити, якщо ви йдете правильним шляхом ProblemDetail і увімкнули підтримку problem details у конфігурації проєкту.
Один каркас для 404, 400 і 409
Зараз хочеться, звісно, одразу зробити «ідеальну модель помилок на всі випадки життя». Але сьогоднішня мета скромніша й важливіша: переконатися, що базовий каркас однаково підходить для різних типів проблем. І 404 Not Found, і 400 Bad Request, і 409 Conflict — це різні смисли, але форма відповіді має залишатися впізнаваною. Клієнт має впізнавати помилку за формою так само легко, як ви впізнаєте JSON-відповідь списку за полем items.
Тобто змінюються значення і додаткові extension fields, а базовий набір type/title/status/detail/instance лишається тим самим. Наприклад, той самий каркас можна використовувати і для конфлікту. Ми поки не розбираємо, коли саме потрібен 409, але показуємо, що структура не змінюється: змінюються лише status, type, title, detail і instance.
// Приклад: конфлікт бізнес-стану, коли операція недопустима в поточному статусі задачі
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT,
"Поточний стан задачі не дозволяє цю операцію"
);
// Класифікуємо проблему стабільним type, щоб її можна було групувати
pd.setType(URI.create("/problems/state-conflict"));
pd.setTitle("Конфлікт стану");
// Привʼязуємо помилку до конкретного ресурсу або запиту
pd.setInstance(URI.create("/api/v1/tasks/42"));
І те саме для 400, коли запит загалом не тієї форми, наприклад тіло запиту не проходить базову перевірку. Знову ж таки, без заглиблення у fieldErrors — лише каркас.
// Приклад: запит невалідний за формою (наприклад, не проходить базову валідацію)
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
"Тіло запиту є невалідним"
);
// type і title тримаємо стабільними, щоб клієнту було простіше розуміти категорію помилки
pd.setType(URI.create("/problems/invalid-input"));
pd.setTitle("Невалідне введення");
// instance допомагає зрозуміти, до якої кінцевої точки належить помилка (особливо в логах і тестах)
pd.setInstance(URI.create("/api/v1/tasks"));
Головне, що потрібно відчути: ми не робимо окремий «формат 404», окремий «формат 409» і третій «формат 400». Ми робимо одну мову помилок, де змінюється зміст, але не граматика.
Що не має потрапити в ProblemDetail
Навіть найкрасивіший ProblemDetail можна зіпсувати однією звичкою: «а давайте просто віддамо назовні ex.getMessage()». Іноді це безпечно, якщо виняток ваш і повідомлення заздалегідь підготовлене. Але часто це призводить до витоку внутрішніх деталей. І це не про параною, а про контракт: клієнт не має залежати від ваших внутрішніх текстів і точно не має бачити технічні назви класів та стек викликів.
Якщо ви віддаєте stack trace, ви одночасно робите дві речі. Перша — видаєте потенційно чутливу інформацію про систему. Друга — робите клієнта залежним від випадкового тексту: завтра ви трохи змінили повідомлення у винятку, і раптом фронтенд-тести почали падати, бо хтось — звісно ж, не ви, а «хтось» — порівнював рядки. Це як побудувати інтеграцію на кольорі кнопки в чужому застосунку: поки кнопка синя — ви щасливі, щойно вона стала зеленою — усе розвалилося.
Тому в мінімальному каркасі ми навмисно тримаємо зовнішній текст під контролем. title і type — стабільні. detail — конкретний, але зовнішній. instance — привʼязаний до запиту. А внутрішні подробиці залишаються в логах сервера, де їм і місце.
5. Типові помилки під час складання ProblemDetail
У цьому місці зазвичай хочеться сказати: «Ну тут усе просто». І так, механічно — просто. Але саме прості речі найчастіше й ламають контракт: не тому, що це складно, а тому, що «та потім виправимо». У помилках API «потім» швидко перетворюється на «ніколи», бо клієнти вже звикли до кривого формату, а змінювати його стає боляче.
Помилка №1: плутати title і detail.
Дуже поширена картина: у title кладуть «Task with id ... not found», а detail роблять загальним «Not found». У підсумку заголовок щоразу різний, його не можна використовувати як стабільний підпис, а detail втрачає сенс конкретики. Лікується просто: title короткий і загальний, detail конкретний і про цей випадок.
Помилка №2: не заповнювати instance, бо «клієнт і так знає URL».
Так, клієнт знає. Але коли помилка потрапляє в логи, у баг-репорт, у звіт тестів або в алерт моніторингу, URI може бути втрачений або неочевидний. instance робить помилку самодостатньою. Це недорога звичка, яка економить багато часу.
Помилка №3: залишати type за замовчуванням і не думати про класифікацію помилок.
Якщо type завжди однаковий або не задається, ви втрачаєте можливість по-людськи групувати помилки. Навіть якщо ви не робите сторінку документації по type, сам факт наявності стабільних type уже допомагає: і в логах, і в тестах, і в майбутній документації.
Помилка №4: намагатися запхати в detail сире повідомлення винятку, особливо фреймворкового.
Повідомлення фреймворка можуть бути довгими, технічними і нестабільними між версіями. Сьогодні одне, завтра інше — і ви самі потім дивуватиметеся, чому у вас «зламався контракт помилок» після оновлення залежностей. detail має бути вашим, зовнішнім і контрольованим.
Помилка №5: робити різні форми помилок для різних контролерів.
Один контролер віддає ProblemDetail, інший — { "error": "..." }, третій — взагалі рядок. Клієнту лишається тільки плакати й писати три парсери. Єдиний нормальний вихід — централізувати обробку і тримати каркас однаковим усюди. Тоді API справді стає передбачуваним.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ