1. Проблема: «нехай сервіс сам розбереться»
Коли ви тільки починаєте писати бекенд, дуже легко потрапити в пастку: «ну це ж очевидно, зараз я швиденько перевірю if‑ами». І так, у маленькому методі це справді виглядає навіть мило: три перевірки, два throw — і ви герой. Проблема в тому, що API — це публічний вхід до вашого застосунку, а не особистий чорновик. Якщо на вході немає зрозумілих правил, усередину починають заносити все підряд: порожні рядки, надто великі тексти, null там, де ви чекали значення, і «теги» у вигляді одного пробілу (так, буває і таке).
Уявіть наш Task Tracker API. Клієнт викликає POST /api/v1/tasks і надсилає JSON. Якщо ви не перевіряєте вхід на межі, «погані» дані прослизнуть у сервіс, далі в репозиторій, а потім десь усередині ви отримаєте виняток, який зовсім не схожий на нормальну відповідь контракту. Клієнту від цього не легше: він не розуміє, що виправити, а ви не розумієте, чому знову «падає створення задачі, але тільки по пʼятницях» (бо по пʼятницях хтось любить вводити в title три пробіли — люди загадкові).
Валідація на межі API розв’язує саме цю проблему: вона перетворює «м’яке побажання розробника» на явне правило входу, причому таке, що спрацьовує однаково для всіх кінцевих точок, а не лише там, де вам не лінь написати черговий if‑вислів. Найважливіша думка тут проста: сервісний шар має займатися змістом операції («створити задачу»), а не санітарною обробкою входу («переконатися, що заголовок не порожній і не містить 50 000 символів»).
2. Межа API в Spring MVC
Фраза «межа API» звучить трохи як «державний кордон», а Spring Boot іноді поводиться як митниця з дуже бадьорим ентузіазмом. Але за змістом усе простіше: межа API — це місце, де зовнішній світ (клієнт, браузер, мобільний застосунок, сусідній сервіс) уперше стикається з вашим кодом у формі ваших типів. У нашому проєкті це насамперед контролери та їхні аргументи: @RequestBody TaskCreateRequest, @RequestParam ..., @PathVariable ....
Якщо говорити зовсім чесно, до контролера відбувається ще кілька кроків: Spring приймає HTTP-запит, обирає метод-обробник, читає тіло запиту і намагається перетворити JSON на DTO. І щойно DTO створено (тобто JSON був коректний), настає ідеальний момент для валідації: у нас є об’єкт, у нього є поля, і ми можемо перевірити правила «дозволено/заборонено» до того, як бізнес-логіка почне щось робити.
Зручно тримати це в голові як невелику схему. Не тому, що вам треба вивчати внутрішній устрій Spring, а тому, що корисно розуміти, «в який момент що відбувається», і перестати сприймати фреймворк як чарівний ліс.
flowchart TD
A[HTTP-запит] --> B["HttpMessageConverter: JSON -> DTO"]
B --> C[Валідація входу: обмеження]
C --> D[Метод контролера]
D --> E[Сервісний шар]
Зверніть увагу на важливу деталь. Якщо JSON пошкоджений, стрілка «JSON -> DTO» не спрацює, і до валідації справа не дійде. Це нормально: ви не можете валідовувати об’єкт, який не змогли створити. Тому «пошкоджений JSON» і «DTO не проходить правила» — це два різні класи проблем, і корисно розрізняти їх уже зараз, навіть без занурення у формат помилок.
3. Ручні перевірки та «договір, що розповзається»
Ручні перевірки самі по собі не є злом. Проблема починається тоді, коли вони стають вашим основним способом описувати контракт, а потім розповзаються по проєкту тонким шаром. Спочатку ви перевіряєте title у create, потім забуваєте перевірити його в update, далі додаєте перевірку довжини опису, але лише в одному місці, а потім ще й змінюєте ліміт з 2 000 символів на 3 000 — і вгадайте, скільки місць треба виправити? Правильно: стільки, скільки ви вже не пам’ятаєте.
Подивіться на типовий ручний варіант. Він здається логічним, доки ви не уявите, що таких методів стане десятки.
public void create(TaskCreateRequest request) {
// Перевірка обов’язкового поля: без заголовка задачу створити не можна
if (request.title() == null || request.title().isBlank()) {
// Недолік такого підходу: формат помилки кожен пише по-своєму
throw new IllegalArgumentException("title є обов’язковим");
}
// Перевірка обмеження довжини: інакше в базу, лог або інтерфейс може полетіти «простирадло»
if (request.description() != null && request.description().length() > 2000) {
throw new IllegalArgumentException("description перевищує допустиму довжину");
}
}
На цьому етапі вам може здаватися, що проблема лише в «зайвому коді». Але справжня проблема в іншому: правило «що таке валідний запит» тепер сховане всередині методу. Клієнт цього не знає, контролер цього не знає, тести дізнаються про це випадково, а інший розробник — лише після третьої спроби надіслати запит і отримати черговий IllegalArgumentException.
Ще одна неприємна річ — ручні перевірки майже неминуче створюють різні варіанти поведінки для різних кінцевих точок. Наприклад, один метод кине IllegalArgumentException з текстом "title є обов’язковим", інший — "Некоректний title", третій — взагалі NullPointerException, бо хтось забув перевірку на null і поліз у trim(). У підсумку клієнт отримує не контракт, а «вгадай мелодію».
І нарешті, ручні if‑перевірки дуже погано тримають дисципліну шарів. Ви легко можете почати робити «трохи валідації» в контролері, «трохи» в сервісі й «ще кілька перевірок» у репозиторії. Код при цьому працюватиме, але читати й підтримувати його стане боляче: правила входу розповзаються по проєкту, як кіт по клавіатурі — нібито мило, але результат сумнівний.
4. DTO та constraints: явний контракт входу
Коли ми говоримо «валідація на межі», це не означає «давайте додамо ще один шар із купою магії». Навпаки, ідея дуже приземлена: якщо правило належить до поля вхідного DTO, воно має бути виражене поруч із цим полем. Тоді DTO перетворюється з «транспортного контейнера» на маленьку специфікацію: що обов’язково, які межі довжини, які формати допустимі.
У Task Tracker API у нас є бізнес-обмеження для задачі: заголовок обов’язковий і має довжину 3..120, опис може бути відсутній, але якщо є — не довший за 2000. І це — ідеальний кандидат для декларативної валідації. Тому що правило не залежить від стану застосунку, не потребує знання бази, не потребує складної логіки. Це санітарна норма: як «у приміщення не можна заносити бензопилу». Не тому, що ми проти бензопил, а тому, що в офісі вони зазвичай зайві.
Для цього фрагмента нам достатньо двох полів — title і description. Решту полів DTO запиту тут свідомо опустимо, щоб не втратити сам принцип: як обмеження живе поруч із полем.
Приклад DTO, який сам несе правила:
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record TaskCreateRequest(
// Обов’язкове поле: не можна передати null, порожній рядок або самі пробіли
@NotBlank
// Обмеження довжини: від 3 до 120 символів
@Size(min = 3, max = 120)
String title,
// Поле може бути відсутнім, але якщо воно є — обмежуємо максимальну довжину
@Size(max = 2000)
String description
) {}
Зверніть увагу на чесну магію інженерії: тут нічого не сховано, усе читається одразу. Відкрили файл — побачили контракт. Не треба шукати, у якому сервісі й у якому методі сьогодні вирішили, що title має бути щонайменше 3 символи.
Це також допомагає підтримувати однаковість між create і update. Якщо поле має той самий зміст у create і update, то й базові обмеження зазвичай мають збігатися. І якщо завтра ви вирішите, що заголовок може мати до 140 символів (бо менеджер задач раптово став поетичною платформою), ви змінюєте правило в одному місці — у DTO. Не в десяти сервісах і не в п’яти контролерах.
5. Тонкий контролер: «пропускний пункт»
Є страх, який майже завжди з’являється у початківців: «Якщо ми перевіряємо на межі, значить контролер стане розумним і товстим?» Ні. Навпаки: правильна валідація робить контролер ще тоншим. Він перестає бути місцем, де ви вручну перевіряєте кожне поле, і стає тим, чим має бути: точкою входу, яка приймає контракт, вмикає перевірку і передає далі.
У Spring MVC це виглядає як дуже маленька, але дуже важлива дія: ви просто кажете фреймворку «ось цей аргумент DTO треба перевірити». І все. Контролер не перетворюється на суддю, він лишається турнікетом.
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/tasks")
class TaskController {
@PutMapping("/{taskId}")
public ResponseEntity<TaskDetailsResponse> update(
// Беремо ідентифікатор із path-параметра URL
@PathVariable String taskId,
// Просимо Spring перевірити DTO за обмеженнями до входу в бізнес-логіку
@Valid @RequestBody TaskPutRequest request
) {
// Якщо DTO невалідний, до цього рядка виконання зазвичай навіть не дійде
var updated = taskService.update(taskId, request);
return ResponseEntity.ok(taskMapper.toDetailsResponse(updated));
}
}
Так, тут з’являється @Valid, і це виглядає як «ще одна анотація». Але зміст важливіший за синтаксис: ви вмикаєте системну перевірку входу. Тепер контролер не зобов’язаний писати ручні перевірки на кшталт title == null || title.isBlank() і не зобов’язаний вирішувати, який виняток кидати. Він просто заявляє: «Цей об’єкт має відповідати правилам, описаним на ньому». А де правила? На DTO. Отже, правила не розмазано по коду, а зосереджено в одному передбачуваному місці.
І ще одне: такий контролер легше читати. Ви бачите сигнатуру методу й майже відразу розумієте контракт: taskId беремо з path, тіло — з JSON, тіло має бути валідним. Це вже не «випадкова функція», а декларація поведінки API.
6. Сервісний шар: менше шуму, більше змісту
Сервісний шар у нашому курсі — це місце, де живе прикладна дія: створити задачу, оновити задачу, змінити статус, додати коментар. Якщо сервіс змушений постійно займатися ручною перевіркою рядків, він швидко перетворюється на довгий «сантехнічний» код, у якому бізнес-логіка тоне в перевірках, trim(), isBlank() і спробах пояснити клієнту, що саме не так.
Коли ви переносите базові правила валідації на межу API і описуєте їх у DTO запиту, сервіс починає працювати з чистішим входом. Він не стає «абсолютно довірливим», але базова санітарія виходить із нього. І код стає ближчим до змісту, а не до боротьби з хаосом.
public Task update(String taskId, TaskPutRequest request) {
// Тут ми припускаємо, що базову санітарну перевірку вже виконано на межі API
Task task = repository.getById(taskId);
// Сервіс займається змістовним оновленням сутності, а не повторною валідацією рядків
task.setTitle(request.title());
task.setDescription(request.description());
task.setAssigneeName(request.assigneeName());
task.setPriority(request.priority());
// Зберігаємо підсумковий стан; на цьому рівні вже можливі доменні перевірки
return repository.save(task);
}
Тут важливо правильно прочитати приклад. Він не каже: «у сервісі ніколи не буде перевірок». Він каже: «у сервісі не повинно бути повторюваних санітарних перевірок, які логічно й дешевше відпрацювати на межі». Сервіс тепер може зосередитися на змісті оновлення, на правилах домену, на роботі зі сховищем і на узгодженості операцій. А не на тому, що title знову хтось прислав одні пробіли.
7. Системні негативні сценарії
Коли вхід перевіряється системно, змінюється не лише код, а й поведінка API як продукту. З’являється передбачуваність: якщо клієнт надіслав невалідний запит, він не «інколи падає», не «інколи створює дивні дані», не «інколи повертає незрозумілий виняток». Запит зупиняється на межі. І це важливо, бо межа — це місце, де ми як розробники ще можемо чесно сказати: «Ви надіслали не те».
Валідація на межі також робить негативні сценарії відтворюваними. Якщо правило виражено в DTO і ввімкнено на вході, ви не забудете застосувати його на іншій кінцевій точці «через неуважність». Помилка перестає бути лотереєю. Для API це величезна різниця: клієнтський код починає довіряти тому, що якщо запит прийнято і він пройшов базові правила, далі вже можна говорити про реальні прикладні проблеми, а не про «технічне сміття на вході».
І ще один момент: такий підхід допомагає вам як команді. DTO з обмеженнями — це документ, який читається прямо в коді. Навіть якщо ви не дивитеся в жодну документацію, навіть якщо Postman-колекція загубилася, навіть якщо README написаний «як вийшло», правила входу все одно лишаються в явному вигляді. І це робить проєкт дорослішим без підключення важкої інфраструктури.
8. Типові помилки під час валідації на межі API
Помилка № 1: перевіряти одне й те саме в двох місцях «про всяк випадок».
Дуже людська реакція: ви додали constraints на DTO, але старі if‑перевірки залишили в сервісі або в контролері, бо «а раптом». У підсумку ви отримуєте дублювання, яке майже неминуче розʼїдеться: в одному місці ліміт 120, в іншому 100, а в третьому взагалі забули оновити. Краще вибрати одну головну межу для базової санітарної перевірки й послідовно тримати її там.
Помилка № 2: ховати правила входу всередині сервісу, бо «контролери мають бути тупими».
Контролери справді мають бути тонкими, але тонкий не означає «безвідповідальний». Тонкий контролер — це той, який приймає запит, запускає інфраструктурні механізми межі (включно з валідацією) і делегує змістовну роботу сервісу. Якщо ви ховаєте правила входу в сервіс, ви змішуєте обов’язки: сервіс починає захищатися від сміття замість того, щоб займатися дією.
Помилка № 3: вважати DTO «просто коробкою для даних» і не ставитися до нього як до контракту.
У REST API request DTO — це частина публічної угоди. Якщо ви не задаєте правила для title, description, assigneeName, то угода стає неявною. Клієнти починають вгадувати, що допустимо, а що ні, і часто вгадують у бік хаосу. DTO з обмеженнями робить правила видимими й захищає API від випадкової гнучкості.
Помилка № 4: кидати IllegalArgumentException як універсальну відповідь на все.
Коли перевірки розмазано по проєкту, розробник часто обирає найшвидший вихід: «якщо погано — кидай IllegalArgumentException». На короткій дистанції це працює, а на довгій призводить до того, що різні методи кидають різні винятки, з різними текстами, і API стає непередбачуваним. Декларативна валідація якраз дисциплінує цей шар: перевірка стає системною, а не «кожен пише як хоче».
Помилка № 5: забути ввімкнути каскадну валідацію вкладених об’єктів.
Якщо у request DTO є вкладені об’єкти або списки (наприклад, List<SubtaskCreateRequest>), то обмеження на вкладених елементах не спрацюють самі собою. Потрібно явно позначати поле як @Valid, інакше ви будете певні, що «валідацію ввімкнено», а на практиці частина входу спокійно проскочить у сервісний шар.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ