PagedResponse<T>: DTO списку

Spring REST & MVC
Рівень 14 , Лекція 1
Відкрита

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, а не «що там сервіс повернув». По-друге, totalElementslong, тому що загальна кількість елементів теоретично може бути більшою, ніж уміщається в 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-розробникам і тестам.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ