1. Контракт list endpoint замість «просто List»
Після вибору кореневого об’єкта питання вже не звучить як «масив чи об’єкт?». Тепер воно практичне: як оформити list endpoint так, щоб клієнт одразу бачив елементи, пагінацію і порядок видачі.
Один раз загорнути список у TaskListResponse недостатньо. Поруч із items дуже швидко з’являються page, size, totalElements, totalPages і sort. Отже, потрібен не одноразовий DTO під один endpoint, а спільний контракт списку для всього проєкту.
Домовмося одразу: для list endpointʼів проєкту саме PagedResponse<T> і вважаємо робочою моделлю.
Ідея PagedResponse<T>: один «конверт»
Тут і з’являється PagedResponse<T> — узагальнений (generic) DTO, який описує список як сутність: у нього є елементи (items) і є метадані для пагінації та сортування. І найприємніше — його можна використовувати повторно для різних типів елементів.
T у PagedResponse<T> — це “тип елемента списку”. Для задач це буде TaskSummaryResponse, для інших списків — інший response DTO. Тобто ми фіксуємо одну кореневу форму відповіді й змінюємо лише “начинку” всередині items. Це як стандартна коробка на складі: форма однакова, а всередині може бути що завгодно — головне, щоб за наклейкою (метаданими) було зрозуміло, що саме ви отримали і як із цим працювати.
Невелика схема — щоб швидко побачити структуру:
flowchart TB
P["PagedResponse<T> (корінь відповіді)"]
P --> I["items: List<T> (дані)"]
P --> Pg["page (номер сторінки)"]
P --> Sz["size (розмір сторінки)"]
P --> Te["totalElements (усього елементів)"]
P --> Tp["totalPages (усього сторінок)"]
P --> Srt["sort (порядок сортування)"]
2. DTO PagedResponse<T>: мінімальна форма
Якщо зробити PagedResponse<T> занадто «розумним», він почне нагадувати енциклопедію на 800 сторінок: наче солідно, але користуватися страшно. Якщо ж зробити його занадто бідним, вийде той самий голий список, тільки в капелюсі. Тому ми обираємо золоту середину: поля, які справді допомагають клієнтові й не тягнуть у контракт зайві технічні деталі.
Ось базова форма, яку зручно покласти у пакет com.example.tasktracker.api.dto.response:
package com.example.tasktracker.api.dto.response;
import java.util.List;
public record PagedResponse<T>(
List<T> items, // елементи поточної сторінки (лише DTO, без доменних моделей)
int page, // номер сторінки (зазвичай 0-based)
int size, // запитаний/прийнятий розмір сторінки
long totalElements, // усього елементів у вибірці (не лише на цій сторінці)
int totalPages, // усього сторінок за даного size
String sort // фактичне сортування, застосоване сервером
) {}
Зверніть увагу на кілька речей. По-перше, items — це строго список DTO, а не «що там сервіс повернув». По-друге, totalElements — long, тому що загальна кількість елементів теоретично може бути більшою, ніж уміщається в int (так, навіть якщо в нашому навчальному репозиторії поки що лише 12 задач — світ жорстокий). По-третє, sort — рядок, бо так і людині, і клієнтові читати простіше.
3. Семантика полів: щоб клієнт не вгадував
Якщо не домовитися про сенс полів, почнеться комедія: сервер думає одне, клієнт — інше, а крайнім чомусь виявляється QA. Тому зараз важливо зафіксувати семантику кожного поля так, щоб вона не змінювалася від endpointʼа до endpointʼа.
Нижче — компактна таблиця, яку можна сприймати як публічну інструкцію до PagedResponse<T>.
| Поле | Тип | Що означає в контракті | Приклад |
|---|---|---|---|
| items | List<T> | Елементи поточної сторінки | 20 задач у summary-вигляді |
| page | int | Номер сторінки (зазвичай zero-based, тобто перша — 0) | 0 |
| size | int | Розмір сторінки як параметр списку (скільки планували віддати на сторінку) | 20 |
| totalElements | long | Скільки елементів усього у вибірці (не “на цій сторінці”, а взагалі) | 42 |
| totalPages | int | Скільки всього сторінок за даного size | 3 |
| sort | String | У якому порядку сервер фактично віддав елементи | "updatedAt,desc" |
Тепер важливий нюанс щодо size. На останній сторінці елементів може бути менше, ніж size. Це нормально. Наприклад, якщо size = 20, а всього 42 елементи, то сторінок буде 3, а на останній сторінці items може містити 2 елементи. У клієнта не повинно виникати відчуття «сервер зламався»: контракт чесно показує, що size — це розмір сторінки як концепції, а скільки прийшло фактично видно з items.length.
totalPages зазвичай обчислюється як округлення вгору. Простий приклад: 42 елементи при size = 20 дають 3 сторінки, а не 2. У коді це часто виглядає так — лише як ілюстрація математики, без зайвої логіки:
long totalElements = 42; // припустимо, у базі/вибірці всього 42 елементи
int size = 20; // хочемо по 20 елементів на сторінку
// Округлення вгору: 42 -> 3 сторінки (20 + 20 + 2)
int totalPages = (int) ((totalElements + size - 1) / size);
System.out.println(totalPages); // 3
4. Spring Data Page не в публічному контракті
Іноді новачки думають: «Навіщо вигадувати PagedResponse, якщо у Spring Data є Page?» Питання логічне, але в межах проєктування API — це пастка. Публічний контракт має бути нашим, а не чужим технічним типом, який з’явився для зручності іншого шару застосунку.
Навіть якщо не зважати на те, що в нашому проєкті поки немає бази даних і Spring Data JPA, головна проблема лишається: Page — це тип, який відображає внутрішню інфраструктуру доступу до даних. Він може містити поля й концепції, які клієнтові не потрібні, а іноді навіть шкодять, бо прив’язують його до того, як сервер зберігає та розбиває дані. Зовнішній контракт не має підказувати клієнтові: «я всередині на Spring Data, приходь і живи з цим». Клієнтові потрібно розуміти JSON, а не влаштування вашого репозиторію.
І ще один прагматичний аргумент: PagedResponse<T> — простий, прозорий DTO. Його можна показати новачку, і він не дивитиметься на вас поглядом «я випадково потрапив на лекцію з квантової фізики?». З Page це трапляється частіше: занадто багато «магії» і «під капотом», а курс зараз узагалі не про це.
5. Вбудовуємо PagedResponse<T> у Task Tracker API
Коли ми говоримо «вбудувати DTO у проєкт», важливо не перетворити це на «давайте зараз реалізуємо всю пагінацію». Наш фокус сьогодні — форма відповіді, тобто контракт. Тому ми спокійно можемо почати з того, що endpoint повертає один елемент і метадані, які вже виглядають переконливо.
Для розмови про PagedResponse<T> нам достатньо скороченого summary DTO: тут важливі контейнер list-response і його метадані, а не повний фінальний набір полів задачі. Для початку зафіксуймо summary DTO для списку задач — короткий, без зайвих деталей. Наприклад, так:
package com.example.tasktracker.api.dto.response;
public record TaskSummaryResponse(
String id, // ідентифікатор задачі (у списку зазвичай достатньо рядка)
String title, // короткий заголовок або назва
String status, // статус (у навчальному варіанті можна рядком)
String priority // пріоритет (аналогічно — рядок, щоб контракт був читабельним)
) {}
Тепер у контролері GET /api/v1/tasks ми повертаємо не список, а PagedResponse<TaskSummaryResponse>. Spring MVC і Spring Boot уміють віддавати JSON із @RestController за замовчуванням, якщо Jackson є на classpath (у нашій базовій конфігурації він є), тобто DTO просто серіалізується у відповідь без ручного “писання JSON рядками”.
Невеликий фрагмент коду — уявіть, що він всередині вашого TaskController:
import java.util.List;
@GetMapping("/api/v1/tasks")
public PagedResponse<TaskSummaryResponse> list() {
// Заглушка: зазвичай елементи надходитимуть із сервісу або репозиторію,
// але форма відповіді (обгортка) вже має бути правильною.
var items = List.of(
new TaskSummaryResponse("t1", "Написати документацію", "TODO", "HIGH")
);
// Метадані списку лежать поруч з items, а не всередині кожного елемента.
return new PagedResponse<>(
items,
0, // page: перша сторінка (0-based)
20, // size: розмір сторінки як параметр
1, // totalElements: усього елементів у вибірці
1, // totalPages: усього сторінок за даного size
"updatedAt,desc" // sort: фактичне сортування
);
}
Тут важливо не те, що items поки «заглушка». Важливо, що корінь відповіді вже правильний, і клієнт із першого дня бачить стабільну форму: є items, є метадані, і вони будуть завжди.
Вигляд у JSON: метадані поруч
DTO — це прекрасно, але реальний контракт — це JSON. Тому корисно один раз побачити очима, що саме отримає споживач API. Коли ми повертаємо PagedResponse<TaskSummaryResponse>, відповідь буде об’єктом, де items — масив, а метадані — звичайні поля поруч:
{
"items": [
{
"id": "t1",
"title": "Написати документацію",
"status": "TODO",
"priority": "HIGH"
}
],
"page": 0,
"size": 20,
"totalElements": 1,
"totalPages": 1,
"sort": "updatedAt,desc"
}
Чому це зручніше за голий масив? Тому що клієнтові не потрібно нічого припускати. Він одразу бачить: «я на сторінці 0», «мені дали 1 елемент», «усього 1 елемент», «усього 1 сторінка», «порядок — updatedAt desc». Навіть якщо зараз у нас 1 елемент, формат лишається таким самим, яким буде і для 10 000 елементів. Стабільність — це коли контракт не змінюється від того, що вам сьогодні пощастило з маленьким набором даних.
Дисципліна: items і метадані
Дуже хочеться в items засунути все підряд, особливо коли «воно ж поруч і зручно». Але items — це бізнес-дані конкретних елементів списку. Метадані списку — це властивості відповіді, а не властивості кожної задачі. Якщо змішати ці рівні, вийде дивна JSON-мішанина, де в кожного елемента раптом є page, totalPages і sort. Це як надрукувати номер замовлення на кожному яблуці в пакеті: технічно можна, але виглядає моторошно.
Тому просте правило звучить так: items відповідає на запитання «які об’єкти я отримав?», а метадані — на запитання «що це за список і як мені його гортати?». Якщо ви дотримуєтеся цього поділу, у вас з’являється приємний бонус: TaskSummaryResponse лишається компактним і зрозумілим, а PagedResponse — універсальним і придатним для повторного використання.
Ще один важливий момент: для списку ми використовуємо summary DTO, а не detail DTO. Detail-модель зазвичай багатша й важча: більше полів, більше вкладених структур, більше null-сценаріїв. Якщо тягнути її в list endpoint, відповідь стає шумною і дорогою — і для мережі, і для мозку. Список має бути швидким, компактним і передбачуваним.
Generics і Jackson: серіалізація
Слово “generic” іноді лякає початківців, бо десь поруч починають говорити про “type erasure”, і в голові з’являється картинка: компілятор стирає типи, а разом із ними — й надію. Насправді для нашого випадку все простіше: ми створюємо об’єкт PagedResponse<TaskSummaryResponse>, у items лежать реальні TaskSummaryResponse, і Jackson спокійно серіалізує все в JSON.
Якщо колись вам знадобиться читати такий JSON назад у Java-тип (наприклад, у тестах або в клієнті), тоді так, може знадобитися TypeReference через стирання типів. Але для серіалізації — тобто «Java → JSON» — додаткова магія зазвичай не потрібна: Jackson справляється з тим, що йому передали. Це саме той випадок, коли узагальнення працюють на вашу користь, а не проти вас.
Головна практична думка: PagedResponse<T> — це не «складний framework-трюк», а звичайний DTO-контейнер. Він живе в API-шарі, читається очима і серіалізується так само, як будь-який інший record.
6. Типові помилки під час роботи з PagedResponse<T>
У цьому місці корисно трохи пригальмувати й подивитися на граблі, на які найчастіше наступають. Вони підступні: здаються дрібницями, але саме з них зазвичай і складається той самий нестабільний контракт, який потім доводиться виправляти під сумний звук клієнтських інтеграцій, що падають.
Помилка № 1: плутати size і фактичну кількість елементів у items.
Якщо ви то повертаєте size = 20 як «запитаний розмір сторінки», то раптом починаєте повертати size = 2 як «цього разу прийшло 2 елементи», клієнтові стає важко передбачати поведінку. Це особливо неприємно на останній сторінці: items і так короткий, і якщо ще й size «стрибає», контракт виглядає нестабільно.
Помилка № 2: неправильно рахувати totalPages і забувати про округлення вгору.
Класика: totalPages = totalElements / size. Для 42/20 вийде 2, і клієнт радісно вирішить, що третьої сторінки не існує, хоча вона є. Потім вам прилетить баг-репорт: «Чому частина задач недоступна?». Винною, звісно, буде не математика, а «погане API».
Помилка № 3: складати метадані всередину елементів списку.
Іноді трапляється підхід: «Нехай кожна задача містить page і sort, так простіше». Простіше — рівно до першого клієнта. Потім йому доводиться писати код, який видзьобує метадані з першого елемента (а якщо список порожній?), або з кожного (а навіщо?). Метадані мають бути на корені відповіді, інакше контракт виглядає так, ніби його збирали в темряві.
Помилка № 4: віддавати в items detail DTO або, гірше, внутрішню модель.
Список має бути компактним. Якщо ви кладете туди detail DTO, відповідь роздувається і стає шумною. Якщо ви кладете туди внутрішню модель, ви ще й відкриваєте клієнтові те, що не планували відкривати: службові поля, внутрішні структури, випадкові майбутні зміни. Для list endpoint потрібен окремий, усвідомлений summary DTO.
Помилка № 5: робити нумерацію сторінок 1-based «тому що людям так звичніше».
У UI справді часто рахують «сторінка 1, 2, 3…». Але API — це договір між програмами. Якщо у вас page = 1 означає першу сторінку, а десь в іншому місці page = 0 означає першу сторінку, ви створюєте собі проблему на рівному місці. Нумерація має бути одна і всюди однакова. І навіть якщо ви обрали 1-based, ви зобов’язані бути залізобетонно послідовні — але тоді майже завжди ускладнюєте життя backend-розробникам і тестам.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ