1. Вступ
Якщо вам колись траплявся API, де POST /tasks повертає одну форму помилок, PUT /tasks/{id} — іншу, а GET /tasks — третю, ви добре знаєте це відчуття: клієнту доводиться писати цілий зоопарк обробників. Здавалося б, усі помилки стосуються вхідних даних, але кожен endpoint «співає свою пісню», а фронтенд перетворюється на перекладача з трьох діалектів на одну нормальну мову.
У нашому Task Tracker API це особливо важливо, бо в нас є і body-запити (POST, PUT, PATCH), і «розумний» list/search через query-параметри (GET /api/v1/tasks), і скрізь є валідація. Клієнтові — навіть якщо це ви самі через .http — потрібен один зрозумілий контракт: у відповідь на невалідні вхідні дані завжди приходить 400, а ще завжди одна й та сама структура, з якої можна програмно зрозуміти, що саме зламалося і де.
Формат payload у нас уже є, джерело помилки вміємо називати, ролі code і message теж розведені. Залишилося перестати тримати це як набір домовленостей у голові та закріпити в проєкті як звичайну частину публічного API. Validation response — не окрема саморобка під одну форму, а той шматок error contract, який особливо чутливий до полів, індексів і груп параметрів.
З погляду довгострокового життя проєкту канон економить вам час. Ви один раз домовляєтеся про форму, а потім не сперечаєтеся на кожному новому полі, як це повернути. І так, це звучить нудно, але саме так і виглядає дорослішання API: менше імпровізації, більше передбачуваності.
2. Структура ValidationErrorResponse
Ролі полів у нас уже узгоджені, тож тут не будемо заново розбирати теорію верхнього рівня. Просто фіксуємо канонічні DTO в коді проєкту: один об’єкт — на всю відповідь, один — на одну деталь помилки.
У Task Tracker API ми фіксуємо канонічну структуру в пакеті com.example.tasktracker.api.dto.error. Для навчального проєкту зручно робити це через record: він короткий, читабельний і добре працює з JSON.
package com.example.tasktracker.api.dto.error;
import java.util.List;
public record ValidationErrorResponse(
// Дублюємо HTTP-статус усередині payload (він має збігатися з реальним статусом відповіді)
int status,
// Загальний верхньорівневий код класу проблем (для швидкої класифікації на клієнті)
String code,
// Короткий, людськозрозумілий опис того, що сталося
String summary,
// Список деталей. Важливо: це завжди масив, навіть якщо помилка одна
List<ValidationErrorDetail> errors
) {}
І деталі:
package com.example.tasktracker.api.dto.error;
public record ValidationErrorDetail(
// Джерело помилки: body/query/path
String source,
// Шлях до проблемного місця у вхідних даних (наприклад: title, tags[1], dateRange)
String path,
// Машиночитний код конкретного порушення (стабільний, короткий)
String code,
// Повідомлення для людини (може змінюватися, код — ні)
String message
) {}
На цьому рівні важлива дисципліна: errors завжди масив, а shape не змінюється між create, update і search. Щойно одне місце проєкту починає віддавати «майже такий самий, але трохи інший» payload, єдиний контракт закінчується.
3. ValidationErrorDetail: source, path, code, message
З деталями важливо не переускладнити їх і не зробити марно загальними. Валідація — річ конкретна: клієнту потрібно знати, яке поле виправляти і чому. Тому ми тримаємо чотири частини: source, path, code, message. І саме в проєкті стає видно, що це не абстракція, а практична необхідність.
source у нашому API — не магія, а чесна підказка. У create/update основний потік іде з body, бо ми валідуюємо JSON-payload DTO. У search-сценарії помилки найчастіше приходять із query, бо ми валідуюємо query-параметри (або критерії через @ModelAttribute). А path потрібен в обох випадках, бо і JSON, і query — це все одно вхідні дані, просто з різних місць.
path — це адреса у світі контракту. Для body він збігається з публічною JSON-назвою поля. Для query — з назвою query-параметра або полем criteria DTO. Для колекцій ми використовуємо індекс: tags[1]. Для cross-field правил — логічний об’єктний шлях на кшталт dateRange, щоб не прив’язуватися до одного поля і не змушувати клієнта вгадувати, що саме не узгоджено.
code і message — пара, де один потрібен машині, інший людині. code ми намагаємося тримати коротким і стабільним, а message пишемо «по-людськи». В ідеальному світі клієнт на UI показує message користувачу й одночасно використовує code для логіки: підсвітити поле, вибрати тип повідомлення, визначити, куди прокрутити сторінку.
4. Словник detail-кодів проєкту
Верхньорівневий код INVALID_INPUT у нас один на всі validation failures. Поруч із DTO потрібен такий самий єдиний словник detail-кодів, щоб create, update і search не почали називати одну й ту саму проблему різними рядками.
package com.example.tasktracker.api.dto.error;
public enum ValidationDetailCode {
// Поле обов’язкове та відсутнє/порожнє
REQUIRED,
// Значення коротше за мінімально допустиме
TOO_SHORT,
// Рядок довший за максимально допустимий
TOO_LONG,
// Число/розмір менше за мінімально допустиме
TOO_SMALL,
// Число/параметр перевищує максимально допустиме значення
TOO_LARGE,
// Невірний формат значення
INVALID_FORMAT,
// Значення не проходить прикладне правило, але не потрапляє до вужчої категорії
INVALID_VALUE,
// Повтор елемента (наприклад, дублікат у списку тегів)
DUPLICATE,
// Некоректний діапазон або поєднання полів
INVALID_RANGE
}
Коди в цьому словнику — не повний каталог усіх можливих обмежень Bean Validation. Нам потрібен практичний мінімум, який реально трапляється в наших сценаріях. Для Task Tracker API типові порушення такі: обов’язковість (REQUIRED), довжина рядка (TOO_LONG і іноді TOO_SHORT), обмеження для чисел (TOO_LARGE / TOO_SMALL), унікальність у колекції (DUPLICATE), узгодженість діапазону (INVALID_RANGE).
Важливо, щоб один і той самий зміст не змінював код від місця до місця. Якщо «size занадто великий» у search-сценарії — це TOO_LARGE, то й у будь-якому іншому місці це теж TOO_LARGE. Це не естетика, а гарантія того, що клієнт може один раз написати обробку і більше не вгадувати.
5. Create: помилки TaskCreateRequest
Create-endpoint — найчесніший постачальник помилок валідації. Клієнт уперше надсилає DTO, уперше дізнається обмеження, уперше може помилитися майже в усьому. І якщо ми повернемо йому «Validation failed», це буде схоже на лікаря, який сказав: «У вас щось не так» — і пішов пити каву. Клієнтові потрібна конкретика.
У нас у проєкті TaskCreateRequest приблизно такий (сильно скорочений, щоб не потонути в анотаціях):
package com.example.tasktracker.api.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
public record TaskCreateRequest(
// Заголовок обов’язковий і має бути в розумних межах за довжиною
@NotBlank(message = "Поле title обов’язкове")
@Size(min = 3, max = 120, message = "title має бути завдовжки від 3 до 120 символів")
String title,
// Опис необов’язковий, але обмежуємо довжину, щоб не приймати «романи»
@Size(max = 2000, message = "description не має бути довшим за 2000 символів")
String description,
// Теги: базово це список рядків (додаткові правила, наприклад унікальність, валідуюємо окремо)
List<String> tags
) {}
Окремо в нас є правило про унікальність тегів без урахування регістру — це вже не «просто анотація», а кастомне правило (ми його обговорювали раніше), але клієнтові все одно потрібно бачити це як звичайну помилку валідації.
Ось приклад запиту, де одразу дві проблеми: обов’язковий title не заповнено коректним значенням, а теги дублюються.
# Створення задачі: спеціально не передаємо коректне значення для title і дублюємо тег
POST /api/v1/tasks
Content-Type: application/json
{
"title": null,
"description": "ok",
"tags": ["java", "Java"]
}
І канонічна відповідь (зверніть увагу: структура рівно та, яку ми фіксували):
{
"status": 400,
"code": "INVALID_INPUT",
"summary": "Вхідні дані не пройшли перевірку",
"errors": [
{ "source": "body", "path": "title", "code": "REQUIRED", "message": "Поле title обов’язкове" },
{ "source": "body", "path": "tags[1]", "code": "DUPLICATE", "message": "Теги мають бути унікальними" }
]
}
Тут важливо не те, як саме Spring назве свій внутрішній FieldError, а те, що назовні виходить наш ValidationErrorDetail зі source="body", точним path і стабільним detail-кодом. Для списку тегів шлях tags[1] уже одразу підходить і для UI, і для тестів.
6. Update: PUT/PATCH і часткова валідація
З update-endpointами часто трапляється дивна річ: розробники починають «особливо обробляти» помилки, бо «це ж оновлення, тут усе інакше». На практиці клієнтові все одно: він надіслав вхідні дані, а сервер сказав «невалідно» — отже, відповідь має бути в тому самому форматі. І це якраз той випадок, коли стабільний контракт рятує від хаосу.
Для PUT у нас зазвичай є TaskPutRequest (повна заміна змінюваної частини). Для PATCH — TaskPatchRequest (часткове оновлення). Але validation-відповідь лишається однаковою і для одного, і для іншого.
Візьмімо зрозумілий приклад: assigneeName не обов’язковий, але якщо він є, то максимум 80 символів. Клієнт надіслав 81 (ми всі таке робили: «ну я просто тестував, чесно»).
package com.example.tasktracker.api.dto.request;
import jakarta.validation.constraints.Size;
public record TaskPutRequest(
// Для прикладу: title можна змінювати, але тут без демонстрації правил
String title,
// Поле необов’язкове, але за наявності обмежуємо довжину
@Size(max = 80, message = "assigneeName не має бути довшим за 80 символів")
String assigneeName
) {}
Запит (умовний):
# Оновлення задачі: передаємо занадто довгий assigneeName, щоб отримати detail валідації
PUT /api/v1/tasks/{taskId}
Content-Type: application/json
{
"title": "Коректний заголовок",
"assigneeName": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
Відповідь — той самий shape:
{
"status": 400,
"code": "INVALID_INPUT",
"summary": "Вхідні дані не пройшли перевірку",
"errors": [
{
"source": "body",
"path": "assigneeName",
"code": "TOO_LONG",
"message": "assigneeName не має бути довшим за 80 символів"
}
]
}
Тут є тонкий, але важливий момент. Update змінює вміст errors, але не змінює форму. У create-сценарії ми частіше ловимо REQUIRED і помилки списку. В update-сценарії частіше ловимо обмеження довжини та формату. Клієнтові це зручно: він один раз написав рендер помилок форми, і він працює і на create, і на edit.
Якщо ви робите PATCH, то частина полів може бути відсутньою (ми це вже обговорювали). Це не привід змінювати форму відповіді. Це привід домовитися, які поля валідуюються лише за наявності. Наприклад, якщо assigneeName передано — застосовуємо обмеження довжини. Якщо не передано — не сваримося. Але якщо вже сваримося, то робимо це тим самим контрактом.
7. Search: валідація query-параметрів і object path
Search/list endpoint виглядає невинно, поки не з’являються page, size, dueAfter, dueBefore, фільтри за статусом, пріоритетом і так далі. І ось тут багато API починають буксувати: помилки query-параметрів повертаються в якомусь іншому форматі, ніж помилки body. Клієнтові боляче, бо він не може обробляти помилки однаково, хоча зміст той самий: вхідні дані не пройшли перевірку.
У нашому Task Tracker API ми хочемо, щоб GET /api/v1/tasks за невалідних параметрів повертав той самий формат, лише зі source: "query".
Припустімо, що size має бути не більшим за 100, а ще в нас є cross-field правило: якщо задано dueAfter і dueBefore, то dueAfter має бути раніше за dueBefore. І ось клієнт надіслав одразу дві проблеми: size=200 і переплутав діапазон дат.
# Пошук/список: навмисно робимо size надто великим і плутаємо dueAfter/dueBefore
GET /api/v1/tasks?page=0&size=200&dueAfter=2026-03-20&dueBefore=2026-03-10
Канонічна відповідь:
{
"status": 400,
"code": "INVALID_INPUT",
"summary": "Вхідні дані не пройшли перевірку",
"errors": [
{ "source": "query", "path": "size", "code": "TOO_LARGE", "message": "size має бути не більшим за 100" },
{ "source": "query", "path": "dateRange", "code": "INVALID_RANGE", "message": "dueAfter має бути раніше за dueBefore" }
]
}
Це дуже показовий приклад, тому що він одночасно демонструє два типи шляхів.
Перший detail — класичний field path: size. Клієнту все ясно, він підсвічує поле size або показує повідомлення поруч.
Другий detail — object path: dateRange. Помилка стосується узгодженості пари параметрів, і якщо ви прив’яжете її до одного з них, наприклад до dueAfter, ви ризикуєте заплутати UI. Логічний шлях dateRange дозволяє клієнту показати помилку на групу, наприклад під двома полями одразу, не сперечаючись із сервером про те, «хто винен більше».
Якщо ви хочете відчути, як object path з’являється на рівні коду cross-field валідатора, то це зазвичай виглядає так: ви явно вказуєте «вузол», до якого належить порушення.
// У кроспольовій валідації важливо явно вказати логічний "вузол" (path),
// щоб клієнт міг відобразити помилку як group-level, а не прив’язану до одного поля.
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("dueAfter має бути раніше за dueBefore")
.addPropertyNode("dateRange")
.addConstraintViolation();
Тут важлива ідея: dateRange стає частиною публічного контракту так само, як і назви полів. Тому ми обираємо його свідомо й використовуємо однаково всюди.
Як закріпити канон у проєкті
Коли ви фіксуєте validation-payload як канон проєкту, корисно ставитися до нього як до звичайного API-контракту, а не як до «внутрішнього DTO для помилок». По суті це такий самий response DTO, як TaskDetailsResponse, просто для негативного шляху.
Практично це означає кілька спокійних правил гігієни. Ми тримаємо DTO помилок в окремому пакеті api.dto.error, щоб їх було легко помітити й не змішувати з request/response-моделями задач. Ми не називаємо поля абияк: errors усюди errors, а не то violations, то fieldErrors, то details. Ми не змінюємо code і summary від endpoint до endpoint, тому що це руйнує ідею верхньорівневої класифікації.
І найважливіше — ми вважаємо path і code частиною стабільної домовленості. Якщо ви сьогодні вирішили, що для діапазону дат шлях dateRange, то завтра не вигадуйте dueDateRange лише тому, що «так красивіше». Клієнт уже міг почати на це спиратися. Контракт — штука без почуття гумору: він запам’ятовує все.
Коли верхній рівень і словник деталей закріплені так жорстко, validation перестає жити окремою саморобкою поруч з іншими помилками. В API з’являється спільний ритм: зверху — клас проблеми, всередині — потрібна деталізація, а для validation ця деталізація просто найдетальніша.
8. Типові помилки validation-відповіді
Помилка №1: різні detail-коди для одного й того самого змісту в різних endpoint-ах.
Це починається безневинно. У POST /tasks ви написали REQUIRED, а в PUT /tasks/{taskId} — MISSING. Потім клієнт або ви самі намагаєтеся зробити єдиний обробник, і раптом виявляється, що «обов’язковість» треба розпізнавати за двома кодами. Уникнути цього просто: маленький словник кодів, який використовується всюди однаково, і звичка не вигадувати нові коди на емоціях.
Помилка №2: path без індексу для колекцій.
Коли ви повертаєте помилку для tags, але не вказуєте tags[1], ви лишаєте клієнта гадати. Навіть якщо UI не вміє підсвічувати конкретний елемент списку, індекс усе одно корисний: він допомагає логам, тестам і відлагодженню. Колекція без індексу — це як адреса «десь у місті»: начебто правда, але марно.
Помилка №3: object-level помилку винесено в окремий формат.
Іноді роблять так: field-помилки йдуть масивом, а cross-field помилки — окремим полем globalErrors. Формально можна, але ви щойно збільшили складність клієнтської обробки вдвічі. Набагато спокійніше тримати все в одному errors, а розрізняти за path: звичайне поле або логічна група (dateRange).
Помилка №4: code деталей = message деталей.
Поки клієнт ручний, це здається не страшним. Але щойно з’являється автоматизація, наприклад UI-логіка, ви виявите, що код «Поле title обов’язкове» не можна стабільно порівнювати: ви змінили крапку на кому — і все зламалося. code має залишатися машинним і стабільним, а message — людськозрозумілим і гнучким.
Помилка №5: змішування validation-помилок і предметних конфліктів в одному payload.
Валідація відповідає на питання «вхід структурно коректний?», а предметні конфлікти — на питання «вхід нормальний, але за правилами так не можна». Якщо ви змішаєте це в одному масиві errors, клієнт втратить межу між «виправ форму» і «зміни сценарій». Усередині однієї відповіді тримайте один клас проблем: тут — лише validation.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ