1. List-endpoint і зростання кількості параметрів
Коли ви лише починаєте писати API, список виглядає невинно: «дай усі задачі» — і готово. Але минають два дні реального життя проєкту (або два запитання від першого клієнта), і раптом з’являється «а можна лише TODO?», «а можна шукати за текстом?», «а можна сторінку і розмір?». У цей момент метод контролера починає виглядати так, ніби він намагається заповнити за вас податкову декларацію.
Уявіть, що ми хочемо приймати хоча б мінімальний набір query-параметрів: сторінка, розмір, статус, фрагмент тексту для пошуку. Якщо робити це «в лоб» через @RequestParam, сигнатура швидко видовжується:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@GetMapping("/api/v1/tasks")
public void findTasks(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) TaskStatus status,
@RequestParam(required = false) String q) {
// page/size — параметри пагінації зі значеннями за замовчуванням (якщо клієнт не передав їх, беремо значення з defaultValue)
// status/q — фільтри: можуть бути відсутні (required = false), тоді будуть null
// ...
}
Поки що терпимо. Але далі зазвичай додаються priority, assigneeName, archived, dueBefore, dueAfter, sort… І контролер починає виглядати як метод, який «і швець, і жнець, і на дуді…» (особливо на дуді). Технічно це працює, але читати й підтримувати стає все важче.
Головна проблема тут навіть не в кількості рядків. Проблема в тому, що сигнатура методу перестає бути «описом контракту» і перетворюється на «список випадковостей». Ви вже не бачите, які параметри належать до одного сценарію, які є обов’язковими, а які просто фільтрами.
2. Criteria DTO: що це і що ні
Criteria DTO — це маленький об’єкт, який описує «умови запиту» для сценарію list/search. Тобто це не «задача», не «модель домену», не «DTO для створення», а акуратний контейнер, куди складаються всі query-параметри, пов’язані з однією кінцевою точкою. За відчуттями це схоже на список перевірки: «сторінка така-то, статус такий-то, шукаємо ось це».
Дуже важливо зрозуміти, чим criteria DTO не є. Він не повинен перетворюватися на «другу Task-модель» і містити поля, що належать самій задачі як сутності (наприклад, id, createdAt та інші «внутрішні деталі»), тому що це не об’єкт даних, а об’єкт запиту. Він також не повинен ставати універсальним «критерієм для всього світу» у стилі CommonSearchCriteriaForEverythingInTheUniverse. У цьому курсі критерії мають бути вузькими й сценарними: TaskSearchCriteria — для задач, CommentSearchCriteria — якщо взагалі знадобиться для коментарів, і так далі.
І ще одне «не»: criteria DTO не повинен тягнути в себе taskId. Якщо endpoint адресує один конкретний ресурс, це шлях, і це інший зміст. Criteria — це про список.
Якщо сказати максимально по-людськи: критерії — це «як я хочу шукати», а Task — це «що я зберігаю». Це різні ролі, і змішувати їх — як намагатися готувати борщ просто в холодильнику: начебто можна, але потім усім дуже сумно.
3. @ModelAttribute: збирання об’єкта з query-параметрів
@ModelAttribute — це механізм Spring MVC, який уміє створити об’єкт і заповнити його значеннями із запиту. У класичному MVC (зі сторінками та формами) це часто використовують для зв’язування полів форми. Але в REST-контролері ми так само можемо застосовувати @ModelAttribute, щоб зібрати параметри з query string. Це як «розумний конструктор аргументів», який позбавляє нас від десятка @RequestParam у сигнатурі.
Ментальна модель тут проста й дуже корисна для новачків: запит приходить у контролер, і до виклику вашого методу Spring намагається підготувати аргументи. Для @ModelAttribute це виглядає так:
flowchart TD
A["HTTP-запит
/api/v1/tasks?page=1&status=TODO&q=docs"] --> B["Spring MVC
розв’язання аргументів"]
B --> C["Створити TaskSearchCriteria
new TaskSearchCriteria()"]
C --> D["Заповнити поля з query params
page=1, status=TODO, q=docs"]
D --> E["Викликати метод контролера
findTasks(criteria)"]
Важливо не переплутати механіку: @ModelAttribute не вигадує окремий спосіб зв’язування. Spring використовує той самий ланцюжок обробки, що й для окремих @RequestParam: сирі значення з query приходять текстом, потім перетворюються в int, TaskStatus, LocalDate і лише після цього об’єкт потрапляє в метод. Тому некоректний enum або дата ламають запит на тому самому місці — ще до входу в метод контролера.
Тобто Spring бере об’єкт, знаходить у ньому поля й сетери, зіставляє імена параметрів (status) з іменами властивостей (status) і намагається виконати перетворення типів. І ось тут важливий зв’язок із попередньою лекцією: типи всередині criteria DTO конвертуватимуться тими самими правилами, що й окремі @RequestParam. Якщо всередині criteria є TaskStatus status, Spring спробує перетворити рядок "TODO" на enum. Якщо є LocalDate dueBefore, він спробує розпарсити дату.
У REST-контролерах є ще одна приємна річ: для «нескладних типів» Spring часто й так сприймає аргумент як @ModelAttribute за замовчуванням. Але для навчального проєкту я рекомендую писати анотацію явно — не тому, що «без неї не працює», а тому, що читач коду (тобто ви через тиждень) не має вгадувати, звідки беруться дані.
4. TaskSearchCriteria: мінімальний каркас
Тепер зберемо наш criteria DTO. Ми хочемо, щоб він відображав вхідні параметри list-endpoint’а GET /api/v1/tasks. При цьому ми не зобов’язані прямо зараз реалізовувати фільтрацію на рівні сервісу — у цій лекції наше завдання саме «правильно прийняти вхід», а не «зробити ідеальний пошук». Але DTO вже має бути нормальним: із типами, зі значеннями за замовчуванням і з зрозумілими іменами.
Почнемо з мінімальної версії. Ключовий прийом із default values тут такий: якщо параметр не прийшов, ми хочемо, щоб в об’єкті залишалися значення за замовчуванням. Найпростіший спосіб — задати їх прямо під час оголошення поля.
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.format.annotation.DateTimeFormat.ISO;
public class TaskSearchCriteria {
// Пагінація: хочемо передбачувану поведінку навіть без параметрів запиту
private int page = 0;
private int size = 20;
// Фільтри: якщо параметра немає, поле лишається null
private TaskStatus status;
private String q;
// Приклад фільтра за датою: якщо параметр не прийшов, поле лишається null
@DateTimeFormat(iso = ISO.DATE)
private LocalDate dueBefore;
// методи доступу (опущено для стислості)
}
Тут одразу є кілька важливих смислів:
Перше: page і size — службові параметри видачі. Ми не хочемо, щоб вони були null, бо «сторінки без сторінки» — дивний філософський об’єкт. Тому int + значення за замовчуванням — нормальний вибір.
Друге: status, q, dueBefore — фільтри. Їхня відсутність — це нормальний сценарій («фільтр не застосовано»), тому null тут цілком допустимий і навіть бажаний: null означає «клієнт не просив фільтрувати за цим полем».
Якщо хочеться показати трохи більш «схожу на реальність» версію (але все ще навчально просту), можна додати кілька полів із майбутньої специфікації endpoint’а. Навіть якщо сервіс поки дивитиметься на них «без ентузіазму», контролер уже зможе коректно приймати контрактний вхід:
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.format.annotation.DateTimeFormat.ISO;
public class TaskSearchCriteria {
// Пагінація (значення за замовчуванням залишаються в об’єкті, якщо клієнт не надіслав параметри)
private int page = 0;
private int size = 20;
// Фільтри за enum: Spring спробує сконвертувати рядок із query у значення enum
private TaskStatus status;
private TaskPriority priority;
// Рядкові фільтри: null означає «не фільтрувати за цим критерієм»
private String assigneeName;
private String tag;
private String q;
// Діапазон дат: кожна межа може бути відсутня окремо
@DateTimeFormat(iso = ISO.DATE)
private LocalDate dueBefore;
@DateTimeFormat(iso = ISO.DATE)
private LocalDate dueAfter;
// методи доступу (опущено)
}
Зверніть увагу: тут усе ще немає taskId. І це добре. taskId — це адресація однієї задачі, а criteria — це умови пошуку задач.
Якщо вас тягне додати поле taskId «про всяк випадок», зупиніться, вдихніть, видихніть і нагадайте собі: @ModelAttribute — це про query, а не про path. А «про всяк випадок» — головний постачальник майбутніх багів.
5. TaskSearchCriteria у TaskController
Тепер найприємніше: сигнатура контролера стає короткою й читабельною. Ми буквально міняємо «простиню параметрів» на один об’єкт. Це схоже на ситуацію, коли ви замість десяти пакетів із магазину берете одну велику сумку: фізика та сама, але жити вам легше.
Приклад контролера в стилі нашого проєкту (із базовим /api/v1/tasks):
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TaskController {
@GetMapping("/api/v1/tasks")
public void findTasks(@ModelAttribute TaskSearchCriteria criteria) {
// criteria вже зібрано з query-параметрів (page/size/status/q/...)
taskService.findTasks(criteria);
}
}
Так, тут taskService не показано — це нормально: у цій лекції нам важлива механіка входу. Але ключовий результат ви вже бачите: метод читається як «знайди задачі за критеріями», а не як «знайди задачі за цим, цим, цим, цим і ще цим».
Щоб картина стала наскрізною на рівні проєкту, можна додати в сервіс метод, який приймає criteria. На цьому етапі можна зробити заглушку, щоб переконатися, що все зв’язується і застосунок запускається:
import java.util.List;
public class TaskService {
public List<Task> findTasks(TaskSearchCriteria criteria) {
// Поки що ігноруємо criteria та повертаємо порожній список — це заглушка для перевірки зв’язування шарів
return List.of(); // поки що без реальної фільтрації
}
}
Це не «фінальна бізнес-логіка», а просто нормальний спосіб перевірити, що контролер уміє отримати об’єкт, а сервіс — прийняти.
Якщо ви хочете швидко переконатися, що критерії справді приходять, можна тимчасово вивести пару полів у лог/консоль (так, це той самий «швидкий відладковий println», який у проді зазвичай не живе, але в навчальному проєкті допомагає зрозуміти механіку):
public List<Task> findTasks(TaskSearchCriteria criteria) {
// Швидка перевірка: переконуємося, що Spring дійсно зібрав об’єкт із query-параметрів
System.out.println("сторінка = " + criteria.getPage()); // наприклад: сторінка = 1
System.out.println("статус = " + criteria.getStatus()); // наприклад: статус = TODO
return List.of();
}
6. Корисні нюанси
Приклади запитів
Тут корисно тримати в голові одну просту річ: всередині TaskSearchCriteria працює той самий механізм перетворення типів, що й у попередній лекції. Тобто status=TODO перетворюється на TaskStatus.TODO, page=1 — на int, а dueBefore=2026-03-21 — на LocalDate. Якщо формат некоректний, об’єкт просто не збереться правильно.
Тепер давайте зробимо те, що особливо корисно новачкам: «перекладемо» реальні URL у значення полів об’єкта. Це той момент, коли @ModelAttribute перестає бути абстрактною анотацією і стає механізмом, який ви можете відчути буквально руками.
# Приклад: пагінація + фільтр за статусом + текстовий пошук
GET http://localhost:8080/api/v1/tasks?page=1&size=5&status=TODO&q=docs
Accept: application/json
Якщо все налаштовано стандартно, Spring збере об’єкт приблизно так:
| Параметр query | Поле в TaskSearchCriteria | Тип поля | Значення |
|---|---|---|---|
| page=1 | page | int | 1 |
| size=5 | size | int | 5 |
| status=TODO | status | TaskStatus | TaskStatus.TODO |
| q=docs | q | String | "docs" |
Якщо клієнт не передав page і size, то в об’єкті залишаться значення за замовчуванням. Це особливо приємний момент: вам не потрібно розмазувати «якщо page не прийшов, то 0» по коду, якщо ви правильно обрали типи й значення за замовчуванням у criteria.
А ось приклад із датою, щоб побачити, що всередині criteria живе той самий механізм перетворення типів, що й у @RequestParam:
# Приклад: фільтр за датою (Spring спробує розпарсити рядок у LocalDate)
GET http://localhost:8080/api/v1/tasks?dueBefore=2026-03-21
Accept: application/json
Якщо в criteria є LocalDate dueBefore, то він отримає значення LocalDate.of(2026, 3, 21). При цьому формат дати — це не «магія дати», а угода конвертера. У навчальному API важливо, щоб ви тримали формат передбачуваним, інакше клієнт надсилатиме 21.03.2026, а сервер — сумуватиме.
Правила хорошого TaskSearchCriteria
У criteria DTO дивовижна доля: він або робить код кращим, або повільно перетворюється на величезний клас, куди «зваливають усе, що шкода втратити». Тому корисно заздалегідь тримати кілька правил, щоб об’єкт залишався інструментом, а не звалищем.
Перше правило: критерії мають бути прив’язані до однієї конкретної кінцевої точки. TaskSearchCriteria — для GET /api/v1/tasks. Якщо у вас з’являється інший сценарій з іншою семантикою, не потрібно намагатися зробити «універсальний критерій», який уміє все, завжди й одразу. Універсальні речі в навчальних проєктах зазвичай стають універсально незрозумілими.
Друге правило: не змішувати канали входу. У criteria мають жити query-параметри, а не path-змінні. Якщо ви бачите бажання засунути в criteria taskId, отже ви вже почали стирати межу «список vs конкретний ресурс».
Третє правило: тримати імена параметрів і полів максимально однаковими. assigneeName у query і assigneeName у полі — це не «педантичність», а спосіб зменшити кількість неочікуваних розбіжностей. Чим менше «магічного мапінгу імен», тим спокійніше вам жити.
Четверте правило: значення за замовчуванням мають жити поруч із входом. Якщо page за замовчуванням 0 і size за замовчуванням 20, то зручно тримати їх саме в criteria. Тоді будь-хто, хто відкрив клас, одразу бачить «як поводиться endpoint без параметрів». Це робить контракт передбачуваним навіть без читання сервісу.
І останнє, дуже людське правило: criteria DTO має залишатися нудним. Це комплімент. Він не повинен містити бізнес-логіки, обчислень, мережевих викликів і філософії. Його завдання — бути контейнером вхідних даних. Чим нудніший — тим краще він виконує свою роботу.
7. Типові помилки: @ModelAttribute і criteria DTO
Помилка №1: перетворювати criteria DTO на “універсальний об’єкт на всі випадки життя”.
Часто з’являється спокуса зробити один SearchCriteria, який підходить і для задач, і для коментарів, і для вкладень, і «на майбутнє ще для користувачів». Зазвичай це закінчується тим, що в об’єкті з’являються поля без чіткої семантики, половина параметрів не належить до поточної кінцевої точки, а контролер приймає «мішок усього». З критеріями працює протилежний принцип: чим він вужчий і сценарніший, тим легше його підтримувати.
Помилка №2: додавати в criteria те, що належить до path (наприклад, taskId).
Це майже завжди ознака того, що ви змішали адресацію і фільтрацію. taskId відповідає на запитання «яку задачу ми чіпаємо», і це шлях, тобто @PathVariable. Criteria відповідає на запитання «як вибрати задачі з колекції», і це query. Якщо засунути taskId у criteria, URI перестає бути читабельним, а логіка входу стає розмитою.
Помилка №3: намагатися зберігати в criteria «готову бізнес-логіку», а не вхідні параметри.
Іноді в criteria починають додавати методи на кшталт isEmpty(), hasSearch() або навіть «розумні» обчислення. Це не завжди погано, але на ранньому етапі краще тримати об’єкт максимально простим: поля + гетери/сетери. Коли критерії починають «розумнішати», вони часто починають сперечатися із сервісом за право бути головними. У нашому проєкті критерії мають залишатися просто описом входу.
Помилка №4: обирати неправильні типи, через які не можна виразити «параметр відсутній».
Якщо параметр фільтра необов’язковий, то int (примітив) може бути поганим вибором, тому що в примітива немає null. Наприклад, фільтр priority краще зберігати як TaskPriority, а не як int priority = 0 (що взагалі означає «0»?). Для необов’язкових речей обирайте типи, де відсутність значення виражається природно — найчастіше це null у посилального типу.
Помилка №5: забути, що всередині criteria працює перетворення типів, і «криві» значення ламають запит ще до сервісу.
Якщо клієнт надіслав status=DOING при тому, що enum знає лише TODO, IN_PROGRESS і так далі, Spring не зможе зібрати TaskSearchCriteria коректно. І ваш метод контролера навіть не викличеться. Це нормально й очікувано, але важливо розуміти це як частину інженерної картини: «помилка сталася на вході, під час складання аргументів». У межах цієї лекції ми не розбираємо, як саме такі помилки перетворюються на HTTP-відповідь, але для розуміння механіки важливо пам’ятати: іноді сервіс «не винен» — до нього просто не дійшло.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ