PagedResponse<T>: DTO листинга

Spring REST & MVC
14 уровень , 1 лекция
Открыта

1. Контракт list endpoint вместо «просто List»

После выбора object-root вопрос уже не звучит как «массив или объект?». Вопрос теперь практический: как оформить list endpoint так, чтобы клиент сразу понимал, где элементы, где pagination и какой порядок выдачи.

Один раз завернуть список в TaskListResponse недостаточно. Рядом с items очень быстро появляются page, size, totalElements, totalPages и sort. Значит, нужен не разовый DTO под один endpoint, а общий list-contract проекта.

Договоримся сразу: для 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 (в нашем baseline он есть), то есть DTO просто сериализуется в ответ без ручного “писательства JSON строками”.

Мини-кусок кода (представьте, что он внутри вашего TaskController):

import java.util.List;

@GetMapping("/api/v1/tasks")
public PagedResponse<TaskSummaryResponse> list() {
    // Заглушка: обычно элементы придут из сервиса/репозитория,
    // но форма ответа (envelope) уже должна быть правильной.
    var items = List.of(
            new TaskSummaryResponse("t1", "Write docs", "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": "Write docs",
      "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-разработчикам и тестам).

1
Задача
Spring REST & MVC, 14 уровень, 1 лекция
Недоступна
PagedResponse для списка статей
PagedResponse для списка статей
1
Задача
Spring REST & MVC, 14 уровень, 1 лекция
Недоступна
Один PagedResponse для двух разных коллекций
Один PagedResponse для двух разных коллекций
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ