1. Детермінованість як частина контракту
Коли ви додаєте до endpoint-а фільтри, сортування й пагінацію, ви майже непомітно перетворюєте просте «дай мені всі задачі» на невеликий контрактний механізм, від якого клієнт починає дуже сильно залежати. У цей момент слово детермінований перестає бути суто математичним і стає UX-поняттям: однаковий запит на однакових даних має повертати однаковий результат. Інакше клієнту складно кешувати відповіді, складно будувати інтерфейс сторінок, складно дебажити баги, і взагалі виникає відчуття, що сервер робить «як вийде».
GET /api/v1/tasks вже вміє не лише page, size і sort, а й критерії відбору: status, priority, tag, archived, текстовий q, діапазон дат. Тепер уже мало просто перелічити параметри. Важливо вплести їх в один і той самий порядок обробки, інакше гарний набір фільтрів швидко перетворюється на джерело випадкових і важко відтворюваних списків.
Найнеприємніший ефект недетермінованості — це елементи, що «гуляють» між сторінками. Користувач відкриває сторінку page=1, бачить задачу “Fix login bug”, оновлює сторінку (або UI просто повторює запит), і раптом цієї задачі на сторінці немає, а замість неї — інша. І ви такі: «Ну це ж просто список». А клієнт уже такий: «Це ж мій робочий день, поверніть мені мої задачі назад».
Важливо зрозуміти тонку думку: детермінованість не означає, що відповідь ніколи не змінюється. Дані можуть змінюватися, задачі можуть додаватися й оновлюватися, і це нормально. Детермінованість означає інше: якщо вхідні параметри однакові й набір даних однаковий, то результат має бути однаковим. Ми в навчальному проєкті робимо сховище в памʼяті, і саме там особливо легко отримати хаос, якщо неправильно вибудувати порядок операцій.
2. Модель pipeline для list endpoint
Щоб не плутатися, корисно змінити картинку в голові. Не «у нас є контролер, він приймає параметри, потім якось повертає JSON», а «у нас є конвеєр обробки колекції». На вході — всі задачі як колекція ресурсів, далі — кілька кроків, кожен із яких трансформує список, і лише наприкінці ми упаковуємо все в PagedResponse. Це майже як виробнича лінія: спочатку відсортували сировину за типом, потім упакували, а вже потім нарізали коробки за партіями. Якщо переплутати кроки — коробки будуть дивні.
Нижче — проста схема того, як ми хочемо бачити GET /api/v1/tasks у нашому Task Tracker API:
flowchart TD
A[TaskRepository.findAll] --> B[Фільтрування: status/priority/tag/q/dueRange...]
B --> C[Сортування: default + whitelist + tie-breaker]
C --> D[Пагінація: page/size -> slice]
D --> E[Відображення: Task -> TaskSummaryResponse]
E --> F[PagedResponse: items + totals + sort + page/size]
Зверніть увагу на одну важливу «нахабність» цієї схеми: вона показує, що пагінація — це не «просто взяти 20 елементів». Це завжди «взяти 20 елементів уже з визначеного, відфільтрованого й відсортованого результату». Якщо у вас ще не народилася ця думка, далі буде боляче — але навчально корисно.
3. Порядок: filtering → sorting → pagination → envelope
Коли в проєкті зʼявляється багато параметрів, у новачка часто виникає бажання «зробити простіше»: наприклад, спочатку нарізати сторінку (pagination), а потім уже фільтрувати, адже так менше елементів перевіряти. Або відсортувати лише поточну сторінку, адже «користувачеві все одно видно лише 20 задач». Логіка начебто є, але контракт ламається. Порядок тут не естетика — це фундамент повторюваності відповіді.
Правильний концептуальний порядок такий: ми спочатку застосовуємо фільтри до повної колекції, бо filtering визначає які елементи взагалі беруть участь у видачі. Потім ми сортуємо вже відфільтрований результат, бо sorting визначає порядок елементів і робить його стабільним. І лише після цього ми ріжемо список на сторінки, бо pagination — це просто «візьми шматок із уже відсортованого результату».
Щоб було легше відчути наслідки, давайте порівняємо помилки порядку не в стилі «так не можна, бо не можна», а за реальними симптомами.
| Помилка в порядку | Що ви робите | Що бачить клієнт | Чому це ламає контракт |
|---|---|---|---|
| Pagination до filtering | Спочатку берете сторінку, потім фільтруєте | На сторінці може виявитися 0 елементів, хоча загалом за фільтром є результати | Ви ріжете «не ту» колекцію: сторінка рахується від повного списку, а фільтр — від шматка |
| Sorting після pagination | Спочатку берете сторінку, потім сортуєте сторінку | Елементи «стрибають» між сторінками, іноді зʼявляються дублікати на різних сторінках | Сортування має бути глобальним, інакше сторінки не фіксуються |
| totalElements рахується по повній колекції | Фільтруєте items, але totals рахуєте «як було» | items порожні, а totalElements=1000 | Метадані починають брехати, UI починає «падати філософськи» |
Ще одна деталь: totalElements і totalPages мають описувати саме відфільтрований набір результатів. Тобто це метадані не «взагалі про всі задачі», а «про задачі, що підходять під поточні параметри запиту». Інакше клієнт не зможе зрозуміти, скільки сторінок існує саме в цій вибірці.
4. Pipeline у TaskService
Зараз буде той момент, коли ми перестаємо говорити загальними словами і перетворюємо конвеєр на код. У навчальному проєкті ми працюємо з in-memory репозиторієм, тому наш сервіс зазвичай робить просту річ: бере всі задачі з репозиторію, потім застосовує фільтри, сортування і пагінацію вручну. Це нормально для курсу, і найважливіше тут — зберегти ясність коду: хай він не найшвидший, зате він передбачуваний і добре пояснює контракт.
Найздоровіша форма цього коду — коли у вас є одна публічна операція в сервісі, наприклад findTasks(...), а всередині неї видно три великі кроки й одне фінальне mapping: filter, sort, paginate, map. Не один величезний метод на 200 рядків, де все перемішано, а маленькі функції, що роблять одну річ.
Скелет методу: конвеєр операцій
Перший крок — написати сервіс так, щоб порядок операцій читався з першого погляду. В ідеалі ви хочете, щоб людина, яка відкриє метод через рік, не робила археологічну експедицію по if-ах, а одразу побачила конвеєр. У цьому прикладі я використовую TaskSearchCriteria як «контейнер параметрів», але сама ідея працює й з окремими параметрами — головне, щоб порядок був очевидним.
import java.util.List;
public PagedResponse<TaskSummaryResponse> findTasks(TaskSearchCriteria criteria) {
// На вході — один узгоджений набір критеріїв, а не розсип розрізнених параметрів.
SortSpec validatedSort = parseAndValidateSort(criteria.sort());
// Усередині працюємо з Task: так filtering, sorting і pagination не залежать від структури JSON.
List<Task> filtered = filter(taskRepository.findAll(), criteria); // 1) звужуємо вибірку
List<Task> sorted = sort(filtered, validatedSort); // 2) фіксуємо порядок
PagedResponse<Task> page = paginate(sorted, criteria.page(), criteria.size(), validatedSort); // 3) нарізаємо на сторінки
// Публічну відповідь збираємо після пагінації, щоб мапити лише поточну сторінку.
return toSummaryPage(page);
}
Тут важливо не загубити маленький, але обовʼязковий перехід: на HTTP-границі sort приходить рядком на кшталт updatedAt,desc. Компаратор так працювати не вміє. Тому перед сортуванням сервер спочатку розбирає цей рядок, перевіряє білий список полів і напрямів і лише потім отримує внутрішній SortSpec.
Filtering: спочатку звузити вибірку
На етапі фільтрування важливо памʼятати: filtering визначає склад вибірки. Ми застосовуємо всі активні умови одночасно (логічне AND): якщо клієнт передав status=IN_PROGRESS і priority=HIGH, задача має підходити під обидві умови. Тут зручно використовувати потік stream(), але важливо не потрапити у типову пастку Java: результат toList() — незмінний список. Якщо ви потім спробуєте його відсортувати, отримаєте UnsupportedOperationException. Тому, якщо ви плануєте сортувати, краще відразу зібрати змінюваний список.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
private List<Task> filter(List<Task> tasks, TaskSearchCriteria c) {
return tasks.stream()
// Тут ми залишаємо лише задачі, що відповідають УСІМ активним умовам.
.filter(task -> matches(task, c))
.collect(Collectors.toCollection(ArrayList::new)); // важливо: список має бути змінюваним (далі буде sort in-place)
}
А ось matches(task, c) — це якраз те місце, де живуть правила з попередніх лекцій: equality filters (status, priority), archived, tag, assigneeName, плюс q і dueAfter/dueBefore. Для лекції про «порядок» нам важливий не кожен конкретний if, а думка: фільтрування має завершитися до сортування і пагінації.
Щоб не перевантажувати метод, зручно тримати matches(...) коротким і ділити на маленькі правила:
private boolean matches(Task task, TaskSearchCriteria c) {
// Це логічне AND: задача має пройти всі групи перевірок.
return matchesBasicFilters(task, c)
&& matchesText(task, c.q())
&& matchesDueDate(task, c.dueAfter(), c.dueBefore());
}
Це не «магія заради краси». Це спосіб втримувати логіку в голові: ми читаємо метод як «базові фільтри + текст + дата», а не як 15 розрізнених умов.
Sorting: tie-breaker для стабільності
Сортування — це не просто «красиво відсортувати список». Для пагінації сортування — це фіксація порядку, без якої сторінки не мають сенсу. Якщо ви не відсортували результат детерміновано, у вас фактично немає поняття “page 2”: це буде «друга випадкова двадцятка».
Друга частина ідеї — tie-breaker. Припустімо, ми сортуємо за updatedAt. У багатьох задач updatedAt може збігатися (особливо в seed data або коли ви оновлюєте кілька задач в одному місці). Якщо компаратор вважає елементи рівними, порядок може стати залежним від початкового порядку колекції. А початковий порядок у нас часто народжується з HashMap, а HashMap не зобовʼязаний бути стабільним за ітерацією. Тому ми додаємо вторинне сортування за id (UUID) — просто щоб порядок був зафіксований.
import java.util.Comparator;
import java.util.List;
private List<Task> sort(List<Task> tasks, SortSpec sort) {
// Tie-breaker за id робить порядок стабільним навіть за рівних значень основного поля сортування.
Comparator<Task> cmp = comparatorFor(sort).thenComparing(Task::getId);
// Важливо: сортуємо in-place, тому список має бути змінюваним (див. filter()).
tasks.sort(cmp);
return tasks;
}
Метод comparatorFor(sort) тут уже працює з перевіреним SortSpec: він робить білий список полів сортування і враховує напрям asc/desc. Тут ключове — thenComparing(Task::getId). Це маленький рядок, який економить години дебагу в майбутньому.
Якщо хочеться побачити tie-breaker зовсім у мініатюрі, то ось такий приклад теж працює:
Comparator<Task> cmp = Comparator
.comparing(Task::getUpdatedAt)
.reversed()
.thenComparing(Task::getId);
Це не «щоб було правильно за підручником». Це для того, щоб у вас не було ситуації, коли одна й та сама задача вчора була на першій сторінці, а сьогодні — на другій, хоча ви нічого не змінювали.
Pagination: ріжемо вже відсортований результат
Пагінація в in-memory варіанті — це просто обчислення індексів. Але тут є два важливі нюанси. Перший: fromIndex може виходити за межі, якщо клієнт запросив page більший, ніж існує сторінок. Це не має перетворюватися на падіння сервера через IndexOutOfBoundsException. Другий: порожня сторінка — це нормальний результат, а не помилка контракту.
import java.util.List;
private <T> List<T> slice(List<T> items, int page, int size) {
// page — це номер сторінки (0-based), тому обчислюємо зміщення через множення.
int fromIndex = page * size;
// Якщо клієнт запросив сторінку за межами результату — повертаємо порожній список, а не помилку.
if (fromIndex >= items.size()) return List.of();
int toIndex = Math.min(fromIndex + size, items.size());
return items.subList(fromIndex, toIndex);
}
Зверніть увагу: ми не робимо 404, не кажемо «помилка: такої сторінки немає». Ми просто повертаємо порожній items, тому що list endpoint залишається list endpointʼом. Клієнт може сам вирішити, що робити: показати «сторінка порожня», повернутися на попередню, перерахувати пагінацію.
Totals: totalElements і totalPages
Фінальний крок — зібрати PagedResponse. Тут легко припуститися двох помилок: порахувати totals по повній колекції й замапити всі елементи до пагінації. У навчальному проєкті це може й не вбити продуктивність, але методично краще робити правильно: рахуємо totals по відфільтрованому списку, потім беремо шматок сторінки, потім мапимо лише цей шматок.
private PagedResponse<Task> paginate(List<Task> sorted, int page, int size, SortSpec sort) {
// totals рахуємо по filtered set (у нас це список, який уже пройшов filter() і sort()).
long totalElements = sorted.size();
// На сторінку беремо лише шматок: це важливо і для контракту, і для продуктивності.
List<Task> pageItems = slice(sorted, page, size);
return new PagedResponse<>(
pageItems,
page,
size,
totalElements,
totalPages(totalElements, size),
sort.toString()
);
}
А обчислення totalPages нехай буде окремою маленькою функцією, щоб не повторювати формулу й не помилятися в цілочисельному діленні:
private int totalPages(long totalElements, int size) {
if (totalElements == 0) return 0;
return (int) ((totalElements + size - 1) / size);
}
Тут ми вибрали семантику totalPages=0, якщо елементів немає. Це чесно відображає ситуацію «сторінок немає». Деякі API повертають 1 (мовляв, «сторінка 0 існує і вона порожня»), але це часто плутає клієнтів. Головне — вибрати одну семантику і дотримуватися її всюди.
І тепер — фінальна точка: публічну відповідь збираємо після пагінації. Так ви мапите лише поточну сторінку, а не весь відфільтрований список, і JSON-структура не втручається в filter/sort logic.
import java.util.List;
public PagedResponse<TaskSummaryResponse> toSummaryPage(PagedResponse<Task> page) {
// Мапимо лише items поточної сторінки, а не весь початковий список задач.
List<TaskSummaryResponse> items = page.items().stream()
.map(taskMapper::toSummaryResponse)
.toList();
// Важливо: метадані пагінації і totals залишаються тими самими — змінюються лише items.
return page.withItems(items);
}
Якщо у вас PagedResponse — record, зручно додати метод withItems(...) (або просто створити новий PagedResponse<...>). Головне, що метадані (page, size, totalElements, totalPages, sort) залишаються тими самими, а змінюється лише список items.
5. Нюанси детермінованості
Коли все «нібито» працює, починається найцікавіше: підступні баги. І тут корисно бути трохи параноїком (у хорошому сенсі). Найчастіше джерело недетермінованості — відсутність явного sort за замовчуванням. Навіть якщо клієнт не передав sort, сервер має застосувати default sort, інакше порядок залежатиме від структури даних (а структура даних в in-memory репозиторії — це часто HashMap). Вчора у вашому HashMap «випадково» був один порядок, сьогодні — інший, і клієнт думає, що сервер «бреше».
Друга точка — сортування за полем із повторюваними значеннями. Ми вже обговорили tie-breaker за id, але важливо відчути, навіщо це потрібно. Якщо компаратор повертає 0 для двох різних задач, то підсумковий порядок може залишитися «як був» (а «як був» — не контракт). Тому thenComparing(id) — це маленький акт турботи про майбутнього себе.
Третя точка — узгодженість totals. Клієнти майже завжди використовують totalElements і totalPages, щоб малювати пагінатор, кнопки Next/Prev, а іноді — щоб перевіряти, чи поточна сторінка взагалі можлива. Якщо items ви рахуєте після фільтрації, а totals — до фільтрації, UI стає «пʼяним»: він показує 50 сторінок, але насправді у видачі 3 елементи.
Четверта точка — порожня сторінка. Дуже хочеться повернути помилку, адже «сторінка не існує». Але в нашому контракті list endpoint має бути спокійним: порожня сторінка — це така сама нормальна відповідь, як порожній список за фільтром. Ми вже домовилися, що 404 — це про відсутність конкретного ресурсу (/tasks/{id}), а не про відсутність елементів у вибірці.
6. Типові помилки під час реалізації list endpoint
Помилка №1: пагінація застосовується до фільтрування «заради оптимізації».
Спочатку здається, що так навіть швидше: перевіряти фільтри на 20 елементах, а не на 200. Але контракт ламається одразу: ви ріжете не ту колекцію. Користувач може отримати порожню сторінку, хоча у відфільтрованій вибірці є елементи, просто вони опинилися на інших сторінках «у повному списку». Правильна модель — спочатку визначити вибірку (filter), потім зафіксувати порядок (sort), потім нарізати (page).
Помилка №2: сортування виконується після нарізки сторінки.
Це одна з найпідступніших помилок: локально на одній сторінці все виглядає красиво, елементи «відсортовані». Але глобально сторінки стають не повʼязаними між собою. Один і той самий елемент може опинитися на різних сторінках у різних запитах, а між сторінками більше немає чіткої межі. Сортування має фіксувати порядок усієї видачі, інакше пагінація перетворюється на лотерею.
Помилка №3: totalElements рахується по повній колекції, а items — по filtered set.
У результаті метадані починають суперечити самим даним. UI малює, наприклад, 50 сторінок, а реальних елементів під фільтром — 3. Клієнт або починає «ганяти» порожні сторінки, або вважає це багом API (що, чесно кажучи, не так уже й неправильно). totalElements — це завжди розмір відфільтрованої вибірки.
Помилка №4: сортування без tie-breaker за унікальним полем.
Коли значення поля сортування повторюються, компаратор часто повертає «рівно», і підсумковий порядок починає залежати від початкового порядку колекції. А початковий порядок у in-memory сховищ часто не стабільний. Додавайте вторинне сортування за id (або іншим унікальним полем) і спіть спокійніше.
Помилка №5: спроба відсортувати список, отриманий через stream().toList().
Це суто Java-пастка останніх версій: toList() повертає незмінний список. Ви викликаєте list.sort(...) — і ловите UnsupportedOperationException у рантаймі. Рішення просте: або збирайте в ArrayList, або створюйте копію перед сортуванням (new ArrayList<>(list)), або сортуйте в stream і збирайте у змінювану колекцію.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ