1. Вступ
Якщо ви тільки починаєте, легко недооцінити page і size: здається, що це лише «внутрішня математика» сервісу, а клієнт нехай уже якось упорається. Але в REST API список — це публічний контракт. Клієнту важливо знати не лише самі елементи, а й за якими правилами ви видаєте фрагмент колекції. Інакше інтеграція перетворюється на гру в здогадки, а тести клієнта — на лотерею.
Уявіть інтерфейс із задачами: користувач гортає список, натискає «Наступна сторінка», очікує побачити продовження, а не повтор перших елементів. Або, навпаки, очікує «20 елементів на сторінці», бо так зручно на екрані. Якщо правила page і size не зафіксовані, кожен клієнт почне тлумачити їх по-своєму: хтось вирішить, що сторінки починаються з 1, хтось — з 0, а хтось подумає, що size — це «останній індекс». А потім ви будете виправляти помилки інтеграції, які насправді не помилки, а недомовленість контракту.
У нашому Task Tracker API ми фіксуємо прості, але важливі домовленості: сторінки zero-based (перша — 0), розмір сторінки за замовчуванням — 20, а максимальний розмір — 100. Ці числа — частина зовнішнього контракту, тому не варто тримати їх «десь у глибині сервісу» без явного рішення. Інакше у вас з’явиться прихований контракт, який ламається від випадкового рефакторингу.
2. Zero-based page: перша сторінка — 0
З нумерацією сторінок завжди виникає одне філософське питання: «а чому не з одиниці, як у книжці?». У реальному житті обидва підходи життєздатні, але для бекенда zero-based дає дуже приємну інженерну простоту. Усередині все майже одразу зводиться до формули offset = page * size, і це дуже схоже на роботу з масивами та списками, де індексація теж починається з нуля. Математика стає нудною — а це комплімент.
Важливо проговорити: zero-based — це не тому, що програмісти люблять нуль. Просто так простіше рахувати зрізи колекції й менше шансів помилитися на +1/-1 у несподіваний момент. Клієнту теж простіше, якщо ви один раз задокументували правило: «перша сторінка — 0». Далі він не думає, а просто дотримується контракту.
Невелика таблиця, щоб побачити логіку очима:
| page | size | offset = page * size | Що це означає людською мовою |
|---|---|---|---|
| 0 | 20 | 0 | перші 20 елементів |
| 1 | 20 | 20 | елементи з 21-го по 40-й |
| 2 | 20 | 40 | елементи з 41-го по 60-й |
| 0 | 5 | 0 | перші 5 елементів |
| 3 | 5 | 15 | елементи з 16-го по 20-й |
У коді нам зручно мати невеликий метод, який перетворює page і size на offset. Це проста річ, але саме з таких «простих речей» потім будується прозорий алгоритм ручної пагінації.
// page і size вже мають бути валідними (наприклад, після Bean Validation)
public static int offset(int page, int size) {
// Класична формула для zero-based пагінації: зміщення = номер сторінки * розмір сторінки
return page * size;
}
// Допоміжний метод для обчислення "правої межі" з урахуванням розміру колекції
public static int toIndex(int page, int size, int total) {
// Не даємо вийти за межу total: верхня межа завжди <= total
return Math.min(offset(page, size) + size, total);
}
Зверніть увагу: ми не робимо тут «магії» і не намагаємося вгадати бажання клієнта. Формула завжди однакова. Саме тому zero-based нумерація в нашому API — фіксоване правило, а не «як піде».
3. size: розмір, defaults і ліміт
Коли ви вперше додаєте size, виникає природне бажання: «нехай клієнт сам вирішує, скільки йому потрібно». І десь у паралельному всесвіті ваш сервер радісно отримує size=100000, а потім ви читаєте логи, де застосунок «випадково» вирішив стати файловим архіватором: почав серіалізувати величезні JSON-відповіді, витрачати пам’ять і час CPU. Тому зрілі API майже завжди мають верхню межу size: це не жадібність, а самозбереження.
У нашому контракті size означає «скільки елементів на одній сторінці». За замовчуванням ми вважаємо size=20, бо це адекватний компроміс між «менше запитів» і «відповідь не перетворюється на роман Толстого». При цьому ми фіксуємо MAX_SIZE = 100. Це не про базу даних (у нас її немає), а про передбачуваність і захист API: клієнти не можуть випадково покласти сервіс одним запитом, а ви можете планувати навантаження.
Щоб це було зрозуміло, корисно тримати в голові простий сенс:
- параметр відсутній — беремо значення за замовчуванням (це нормальна гілка);
- параметр є, але виходить за допустимий діапазон — це invalid input.
Таблиця «що вважаємо нормальним, а що — ні» для нашого контракту:
| Параметр | Приклад | Що це | Очікувана реакція API |
|---|---|---|---|
| size відсутній | (немає size) | клієнт не уточнив | беремо |
| size=20 | size=20 | валідний запит | використовуємо |
| size=0 | size=0 | помилка контракту | 400 Bad Request |
| size=-5 | size=-5 | помилка контракту | 400 Bad Request |
| size=101 | size=101 | порушено ліміт | 400 Bad Request |
Найважливіший момент: ми не «лагодимо» запит клієнта мовчки. Тобто ми не повинні автоматично перетворювати size=1000 на size=100, тому що тоді у вас з’являється прихована поведінка: клієнт думає, що попросив тисячу, а отримав сто, і починає будувати хибні очікування. Контракт має бути чесним: якщо ліміт порушено, це помилка клієнта, а не «сервер тихенько зробив по-своєму».
4. Відсутній параметр vs невалідне значення
Тепер найхитріша частина, через яку в новачків часто болить голова. Нам потрібно розрізняти дві ситуації: параметр не надійшов і параметр надійшов, але є некоректним. У Spring MVC це розрізнення дуже зручно виражається через типи: якщо ви використовуєте Integer, то відсутність параметра перетворюється на null. А null — це чудовий сигнал «використовуємо значення за замовчуванням». Якщо ж ви використовуєте int, то null неможливий, і ви втрачаєте саму можливість явно виразити, що параметр відсутній.
Подивіться на поганий варіант criteria DTO: він компілюється, але веде до неприємних сюрпризів:
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
// Поганий варіант: примітиви не вміють зберігати null, тому "параметр відсутній" втрачається як окремий сигнал
public record TaskSearchCriteria(
@Min(0) int page, // тут уже не можна явно відрізнити відсутність page від технічної поведінки binding
@Min(1) @Max(100) int size // для size це особливо болісно: ми втрачаємо чесний шлях "параметр не надійшов -> беремо значення за замовчуванням"
) {}
Якщо клієнт викличе GET /api/v1/tasks без параметрів, ви вже не можете чесно побачити сам факт відсутності значення: примітив не залишає null як сигнал, і далі доводиться змішувати відсутність параметра з тим, як механізм зв’язування технічно заповнює поле. page ще може випадково поводитися терпимо, а size майже одразу конфліктує з ідеєю значення за замовчуванням. Проблема не в тому, що «Spring завжди підставить 0», а в тому, що примітив узагалі не виражає відсутність параметра як частину контракту.
Правильний підхід — використовувати wrapper-типи Integer і дозволити null існувати як «параметр відсутній»:
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
// Правильний варіант: Integer допускає null => можна відрізнити "немає параметра" від "поганого значення"
public record TaskSearchCriteria(
@Min(0) Integer page, // null означає "не надійшов" -> далі підставимо значення за замовчуванням
@Min(1) @Max(100) Integer size // null означає "не надійшов" -> далі підставимо значення за замовчуванням
) {}
Тут є ще одна приємна річ у Bean Validation: обмеження на кшталт @Min і @Max не спрацьовують на null. Це ідеально для нас, тому що null означає «параметр відсутній, беремо значення за замовчуванням», а не «помилка». Якби ми повісили @NotNull, ми б зробили параметр обов’язковим, і тоді відсутність перетворилася б на 400. Але нам це не потрібно, бо значення за замовчуванням — частина контракту.
5. TaskSearchCriteria: робимо page і size частиною request DTO
Усе, що ми обговорювали вище, має бути зафіксовано не в голові, а в коді — причому так, щоб це читалося як контракт. Найзручніший спосіб — покласти параметри в TaskSearchCriteria, який уже використовується як вхідна модель для endpoint’а списку. Тоді контролер залишається тонким: він просто приймає criteria, запускає перевірку і передає далі. А контракт стає очевидним: «у списку є page і size».
Нижче — канонічний, компактний варіант TaskSearchCriteria для сьогоднішньої лекції. Зверніть увагу на Integer, на @Min/@Max і на те, що sort поки що просто рядок.
package com.example.tasktracker.api.dto.request;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
// Request DTO для query-параметрів endpoint'а списку: page/size/sort
public record TaskSearchCriteria(
@Min(0) Integer page, // null => клієнт не передав page (далі підставимо значення за замовчуванням)
@Min(1) @Max(100) Integer size, // null => клієнт не передав size (далі підставимо значення за замовчуванням)
String sort // рядковий опис сортування
) {}
Саме тому criteria DTO корисний навіть із трьома полями: коли список почне рости, сигнатура контролера не перетвориться на набір із десяти @RequestParam.
Далі — контролер. Ми використовуємо @ModelAttribute, щоб Spring зібрав query-параметри в цей DTO, і @Valid, щоб діапазони почали перевірятися. Контролер не зобов’язаний знати, що size за замовчуванням — 20; це ми нормалізуємо далі, в одній точці.
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
@GetMapping("/api/v1/tasks")
public PagedResponse<TaskSummaryResponse> listTasks(
@Valid @ModelAttribute TaskSearchCriteria criteria) {
// Контролер залишається тонким: лише приймає та перевіряє вхідні дані
return taskService.findPage(criteria);
}
Саме так і має виглядати здорова сигнатура list endpoint’а: вона «кричить» про контракт. Клієнт читає документацію (або Swagger пізніше) і бачить: так, є page, size, sort. А всередині Spring MVC у нас автоматично вмикається перевірка діапазонів і, якщо щось не так, запит потрапляє в уже наявний потік 400 Bad Request з Problem Details.
6. Єдина точка нормалізації: defaults і маленький value object
Зараз ми підійшли до ключової ідеї лекції: validation і defaults — це різні задачі. Validation відповідає на запитання «значення допустиме?». Defaults відповідають на запитання «що робити, якщо значення немає?». Якщо переплутати ці дві речі, ви або почнете повертати помилки там, де має бути значення за замовчуванням, або почнете «мовчки лагодити» невалідне введення, що теж погано.
Тому нам потрібна єдина точка, де ми вирішуємо: null → значення за замовчуванням. Зазвичай це або окремий клас із константами, або маленький value object, який уміє збиратися з вхідних значень.
Почнімо з констант. Винести їх корисно: ви не хочете, щоб 20 і 100 жили в трьох різних місцях і одного дня почали суперечити одне одному.
// Єдине місце для значень за замовчуванням і лімітів: так контракт не "розповзеться" по коду
public final class PaginationDefaults {
public static final int DEFAULT_PAGE = 0;
public static final int DEFAULT_SIZE = 20;
public static final int MAX_SIZE = 100;
// Забороняємо створення екземплярів: це утилітний клас із константами
private PaginationDefaults() {
}
}
Тепер введімо маленький value object Pagination. Він уже не про web, а про «нормалізовану» пагінацію: у ньому завжди є конкретні int page і int size, тому що null ми усуваємо в одному місці.
import static com.example.tasktracker.domain.model.PaginationDefaults.*;
// Нормалізована пагінація: тут уже немає null, лише конкретні числа
public record Pagination(int page, int size) {
public static Pagination of(Integer page, Integer size) {
// null означає: параметр був відсутній -> підставляємо значення за замовчуванням із контракту
int p = (page == null) ? DEFAULT_PAGE : page;
int s = (size == null) ? DEFAULT_SIZE : size;
// Валідацію діапазонів (наприклад, MAX_SIZE) очікуємо на вході, через Bean Validation
return new Pagination(p, s);
}
}
Зверніть увагу на тонкість: цей код не займається перевіркою. Він не перевіряє size > 100, бо це вже має спіймати Bean Validation на вході. Такий розділений підхід робить систему зрозумілішою: одне місце відповідає за «можна/не можна», інше — за «якщо не надійшло, що вважаємо за замовчуванням».
І, щоб далі не плодити повторювану математику, корисно дати Pagination пару очевидних методів. Це як дати термометрові шкалу: наче й так можна здогадатися, але зі шкалою жити зручніше.
public int offset() {
return page * size;
}
public int limit() {
return size;
}
Тепер будь-який сервіс, який робить list endpoint, може сказати: «дай мені нормалізовану пагінацію», і далі працювати однаково. А якщо ви пізніше вирішите змінити default size з 20 на 50 (у навчальному проєкті — рідко, але в реальному житті буває), ви зміните це в одному місці, а не в десяти.
7. Пайплайн Spring MVC для list endpoint
Коли ви дивитеся на контролер з @ModelAttribute, легко почати думати, що «Spring сам усе зробить» — і це той самий момент, коли програміст починає вірити в магію. Насправді тут усе досить механічно: Spring спочатку намагається зібрати DTO з query-параметрів, потім запускає перевірку, потім ви потрапляєте в метод контролера, і вже всередині сервісу ви нормалізуєте значення за замовчуванням. Важливо розуміти порядок, щоб не боротися з уявними привидами, якщо щось піде не так.
Зручно уявити це як простий ланцюжок:
flowchart TD
A["HTTP GET /api/v1/tasks?page=&size="] --> B[Зв’язування в TaskSearchCriteria]
B --> C["@Valid: Bean Validation"]
C -->|якщо ok| D[Метод контролера]
C -->|якщо помилка| E[GlobalExceptionHandler -> ProblemDetail 400]
D --> F["Сервіс: Pagination.of(criteria.page, criteria.size)"]
F --> G[Далі: сортування та поділ на сторінки]
Нас сьогодні цікавить саме середина: Binding -> Validation -> Defaults.
У сервісі це виглядає доволі нудно, але це добра нудьга — передбачувана. Ми беремо TaskSearchCriteria, будуємо Pagination і далі вже працюємо з нормальними числами без null.
import com.example.tasktracker.domain.model.Pagination;
public Pagination resolvePagination(TaskSearchCriteria criteria) {
return Pagination.of(criteria.page(), criteria.size());
}
А щоб відчути сенс zero-based, можна вивести offset і побачити, як одне правило перетворюється на «шматок списку»:
Pagination p = Pagination.of(criteria.page(), criteria.size());
int fromIndex = p.offset(); // page * size
int toIndex = fromIndex + p.limit(); // поки без урахування меж
Для поточного фрагмента достатньо, що page і size вже стали валідними, нормалізованими й однаково трактуються всюди. Далі алгоритм нарізування просто використовує ці числа без додаткових припущень.
8. Як це виглядає для клієнта
Коли ви проєктуєте контракт, корисно іноді вийти за межі сервісу й подивитися на API очима клієнта. Клієнту байдуже, як ви зберігаєте список задач і яким методом рахуєте offset. Йому важливо, що він відправив у URL і що отримав у відповіді. Тому давайте подивимося на кілька запитів як на реальні .http сценарії.
Простий успішний сценарій без параметрів: клієнт нічого не передав, ми беремо значення за замовчуванням.
### список задач (за замовчуванням)
GET http://localhost:8080/api/v1/tasks
Accept: application/json
З погляду контракту це означає page=0, size=20. У відповіді ми зобов’язані відобразити це явно:
{
"items": [],
"page": 0,
"size": 20,
"totalElements": 0,
"totalPages": 0,
"sort": "updatedAt,desc"
}
Тут items порожній — це нормально. Але зверніть увагу: page і size все одно присутні й показують клієнту, за якими правилами ви відповідали.
Тепер запит із явно заданими параметрами:
### список задач (page 1, size 5)
GET http://localhost:8080/api/v1/tasks?page=1&size=5
Accept: application/json
Якщо параметри валідні, сервер не «свариться», а просто застосує їх.
А тепер — від’ємна сторінка. Це не «особливий випадок», а звичайне невалідне введення.
### невалідна сторінка
GET http://localhost:8080/api/v1/tasks?page=-1&size=20
Accept: application/json
Очікуємо 400 Bad Request і Problem Details. Ваш конкретний формат залежить від того, як ви фіналізували error contract раніше, але сенс приблизно такий: статус 400, код INVALID_INPUT, і в деталях буде вказано, що поле page не пройшло @Min(0).
{
"title": "Некоректний запит",
"status": 400,
"detail": "Перевірка не пройдена",
"instance": "/api/v1/tasks",
"code": "INVALID_INPUT",
"fieldErrors": [
{
"field": "page",
"message": "має бути не меншим за 0"
}
]
}
І ще один важливий негативний сценарій: занадто великий size.
### невалідний size (завеликий)
GET http://localhost:8080/api/v1/tasks?page=0&size=101
Accept: application/json
Це теж 400, бо клієнт порушив ліміт. І це принципово: ми не робимо «ну гаразд, я зрозумів, ти хотів багато, але я дам менше». Контракт має бути чесним і відтворюваним.
І нарешті, класика жанру: клієнт передав не число.
### невалідний size (не число)
GET http://localhost:8080/api/v1/tasks?page=0&size=oops
Accept: application/json
Це вже не Bean Validation, а помилка binding/conversion, яка станеться ще до того, як ваш сервіс побачить criteria. Але для клієнта це все одно має бути нормальний 400 у вашому єдиному error contract. І ось тут особливо приємно, що ми вже зробили глобальну обробку помилок раніше: клієнт не побачить stack trace і випадкові повідомлення, він побачить структуровану помилку.
9. Типові помилки під час роботи з page і size
Помилка №1: зробити size примітивом int і випадково заборонити запит без параметрів.
Це одна з найчастіших пасток. Ви пишете int size, додаєте @Min(1), а потім дивуєтеся, чому GET /tasks без size дає 400. Причина банальна: примітив не вміє виражати відсутність параметра як окремий сигнал. У нашому контракті відсутність параметра має приводити до значення за замовчуванням, тому використовуйте Integer, а не int.
Помилка №2: переплутати «відсутній» і «невалідний» та почати «виправляти» невалідне введення.
Іноді здається зручним «обрізати» size: якщо більше 100 — зробити 100. Але це робить поведінку невідтворюваною для клієнта. Клієнт буде думати, що запросив 1000, а ви тихо дали 100. У підсумку інтерфейс клієнта і кешування запитів починають жити в альтернативній реальності. Правильніше вважати size > 100 invalid input і повертати 400 через ваш єдиний error contract.
Помилка №3: тримати значення за замовчуванням у кількох місцях і одного дня отримати суперечність.
Сьогодні ви написали DEFAULT_SIZE = 20 у сервісі, завтра — defaultValue = "50" у контролері «для зручності», а післязавтра ще десь у тестах очікуєте 20. У підсумку один і той самий запит GET /tasks поводиться по-різному в різних гілках коду, а ви ловите дивні помилки. Ліки прості: значення за замовчуванням і ліміти мають жити в одному місці (константи + нормалізатор) і використовуватися всюди однаково.
Помилка №4: випадково зробити параметри обов’язковими, повісивши @NotNull.
@NotNull на page або size виглядає логічно, якщо думати: «ну це ж важливі параметри». Але в нашому контракті вони не обов’язкові, бо в нас є значення за замовчуванням. Якщо повісити @NotNull, відсутність параметра перетвориться на 400, і ви втратите головний сенс значень за замовчуванням. Для optional-параметрів зазвичай достатньо @Min/@Max на wrapper-типи та окремої нормалізації null.
Помилка №5: втратити правило zero-based і почати в одному місці рахувати сторінки з 0, а в іншому — з 1.
Це особливо неприємно, бо помилка не завжди помітна одразу: ви можете візуально бачити, що щось не так, але в коді все виглядає розумно. Рішення — зафіксувати правило в одному місці (і в документації), і завжди рахувати offset за формулою page * size. Якщо раптом десь з’явилося (page - 1) * size, значить, у проєкт просочилася альтернативна реальність.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ