1. Ручная пагинация: сервис vs контроллер
Когда вы впервые пишете GET /tasks, контроллер так и просится стать “главным героем”: он же видит query-параметры, он же возвращает ответ, значит, пусть он и режет список на страницы. Но если мы так сделаем, контроллер быстро превратится в кухню с десятью кастрюлями, где варится всё сразу — и сортировка, и пагинация, и маппинг, и “ой, а где моя бизнес-логика”.
В нашем Task Tracker API контроллер должен оставаться тонким по простой причине: он отвечает за web-часть контракта (принять вход, отдать выход), а не за вычисление “какой кусок списка вернуть”. Этот кусок — прикладная логика обработки коллекции, и ей место в сервисном слое. Плюс мы уже выстроили единый error flow: если в сервисе что-то не так (например, невалидный sort), сервис кидает понятное исключение, а @ControllerAdvice превращает его в правильный Problem Details ответ.
В итоге архитектурно это выглядит очень спокойно: контроллер получает TaskSearchCriteria, вызывает taskService.findPage(criteria) и возвращает PagedResponse<TaskSummaryResponse>. Всё “умное” происходит в сервисе, и это помогает нам не размазывать одинаковые алгоритмы по разным endpoint’ам.
Небольшой “контрольный” фрагмент контроллера, чтобы почувствовать, насколько он должен быть простым:
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {
@GetMapping
public PagedResponse<TaskSummaryResponse> list(
@Valid @ModelAttribute TaskSearchCriteria criteria) {
// criteria автоматически собирается из query-параметров и проходит валидацию
// контроллер ничего не режет и не сортирует — только делегирует в сервис
return taskService.findPage(criteria);
}
}
Обратите внимание, что контроллер в идеале вообще не знает, как устроены from и to, как считается totalPages, и даже как именно реализован stable sorting. Он просто делегирует — как взрослый человек, который умеет говорить “это не моя работа” без чувства вины.
2. Пайплайн list-endpoint
Ручная пагинация хороша тем, что она честная: вы буквально пишете “сначала сделай A, потом B, потом C”. Плохая новость: если перепутать порядок, всё тоже честно сломается. Поэтому нам важно зафиксировать пайплайн — то есть последовательность шагов, которая из “сырой коллекции задач” делает “страницу задач с метаданными”.
Чтобы не превращать это в абстрактную философию, удобнее смотреть на алгоритм как на конвейер. В нашем сервисе он (в упрощённом виде) выглядит так:
flowchart TD
%% Схема пайплайна: сначала подготовка параметров, затем сортировка, затем нарезка
A[TaskSearchCriteria] --> B["Нормализовать page/size"]
B --> C["Разобрать sort + проверить whitelist"]
C --> D[Взять все задачи из репозитория]
D --> E[Отсортировать]
E --> F["Срезать страницу: from/to"]
F --> G[Смаппить в TaskSummaryResponse]
G --> H[Собрать PagedResponse]
Важный психологический момент для новичка: сортировка должна происходить до пагинации. Если сначала “отрезать” 20 элементов, а потом отсортировать эти 20, вы будете сортировать не “список задач”, а “случайный фрагмент списка задач”. Клиент будет менять page и получать непредсказуемые пересечения элементов между страницами. Это тот случай, когда баг выглядит как “вроде работает, но мне не нравится”, а такие баги особенно неприятные.
Ещё один момент: нормализация (null -> default) — это отдельный маленький шаг, который хочется сделать аккуратно и один раз. Мы не считаем null ошибкой; null — это “клиент не указал параметр”. А вот значения, которые нарушают диапазон, должны отфильтровываться validation-слоем. Поэтому в сервисе мы обычно не делаем “сто проверок на всякий случай”, а просто применяем defaults.
3. Нарезка страницы: from/to и subList
Когда вы впервые реализуете пагинацию, мозг обычно пытается усложнить: “а вдруг size — это последний индекс?”, “а вдруг page начинается с 1?”, “а вдруг to включительно?”. Хорошая новость: нам не нужно изобретать математику. У нас есть простая договорённость: page — номер страницы (zero-based), size — количество элементов. Значит, первый элемент на странице — page * size.
В сервисном коде это удобно держать в отдельном маленьком методе slice(...), который делает ровно одну вещь: берёт отсортированный список и возвращает нужный фрагмент. И очень важно: если from уже за пределами списка, это не исключение. Это просто пустой items (а не “ошибка” и не “404”). Выглядит это примерно так:
import java.util.List;
private <T> List<T> slice(List<T> source, int page, int size) {
// from — первый индекс страницы (включительно)
int from = page * size;
// Если страница "далеко", возвращаем пустой список — это нормальный сценарий list-endpoint'а
if (from >= source.size()) {
return List.of();
}
// to — верхняя граница (НЕ включительно), поэтому используем Math.min для хвоста списка
int to = Math.min(from + size, source.size());
return source.subList(from, to);
}
Здесь есть несколько вещей, которые лучше прямо проговорить, потому что они потом спасают часы отладки. Во-первых, subList(from, to) работает по полуинтервалу: from включительно, to не включительно. То есть если вы хотите 20 элементов, to должен быть from + 20, а не from + 19. Во-вторых, to нельзя “перегнать” за размер списка, иначе вы получите IndexOutOfBoundsException и будете грустно смотреть на stack trace, как на письмо из прошлого с угрозами.
И третий момент: метод slice(...) работает только если page и size уже нормализованы и валидны. Поэтому в нашем пайплайне нормализация и validation стоят до него, иначе вы рискуете получить, например, size=0 и деление на ноль в других местах.
4. Метаданные: totalElements и totalPages
В пагинированном ответе важны не только items, но и метаданные. Если вы отдаёте клиенту только текущие элементы, клиент не понимает, сколько вообще задач существует и сколько страниц у него впереди. Это превращает API в игру “угадай, есть ли следующая страница”, а мы стараемся в такие игры не играть, даже если очень хочется.
Здесь главное правило: totalElements — это размер всей выборки (после сортировки, но до нарезки страницы). То есть считать totalElements = items.size() — почти всегда ошибка, потому что на первой странице items.size() может быть 20, на второй тоже 20, а всего задач 57. Клиенту важна цифра 57, иначе он не сможет нормально построить пагинацию в UI.
totalPages тоже надо считать от всей выборки и size. В нашем договоре удобно зафиксировать правило: если элементов 0, то страниц 0. Тогда пустая коллекция выглядит “честно пустой”, без странного “одна пустая страница”. Реализация может быть математически простой:
private int totalPages(long totalElements, int size) {
// Пустая выборка => 0 страниц (а не "1 пустая страница")
if (totalElements == 0) {
return 0;
}
// Округление вверх для целых: (total + size - 1) / size
return (int) ((totalElements + size - 1) / size);
}
Здесь используется классическая формула “округление вверх” для целых чисел. Для новичков это звучит как магия, но на практике всё просто: мы добавляем size - 1, чтобы любой неполный хвост тоже дал дополнительную страницу. Например, 57 элементов при size=20 дают (57 + 19) / 20 = 3.
И ещё маленькая инженерная мысль: totalElements лучше хранить как long, а не как int. Даже если сейчас у нас in-memory и учебный проект, привычка “количество элементов может быть большим” — полезная и почти бесплатная.
5. Сборка PagedResponse<TaskSummaryResponse>
Теперь у нас есть все “кирпичики”: нормализация page/size, разбор сортировки, сортировка списка, нарезка страницы, подсчёт метаданных. Осталось собрать это в один метод сервиса так, чтобы он был читаемым, а не выглядел как “свиток из 200 строк, написанный в 3 часа ночи”.
На этом шаге нам не нужны новые сущности. Pagination.of(...) уже переводит null в defaults, parseSort(...) превращает query-строку в SortSpec, а buildComparator(...) собирает детерминированный порядок. Сервис просто связывает эти части в один сценарий.
import java.util.List;
public PagedResponse<TaskSummaryResponse> findPage(TaskSearchCriteria criteria) {
// Pagination.of(...) уже решает вопрос defaults: null -> контрактные значения
Pagination pagination = Pagination.of(criteria.page(), criteria.size());
// sort разбираем и валидируем по тем же правилам, которые уже зафиксировали для public contract
SortSpec sort = parseSort(criteria.sort());
// Сортировка идёт ДО slice, иначе страницы будут "прыгать"
List<Task> sorted = taskRepository.findAll().stream()
.sorted(buildComparator(sort))
.toList();
// Маппинг делаем ПОСЛЕ slice, чтобы не делать лишнюю работу
List<TaskSummaryResponse> items = slice(sorted, pagination.page(), pagination.size()).stream()
.map(taskMapper::toSummaryResponse)
.toList();
long totalElements = sorted.size();
return new PagedResponse<>(
items,
pagination.page(),
pagination.size(),
totalElements,
totalPages(totalElements, pagination.size()),
sort.toRaw()
);
}
Здесь удобно заметить две вещи. Во-первых, Pagination.of(...) уже забрал на себя всю возню с null -> default, поэтому сервис дальше живёт с нормальными числами. Во-вторых, SortSpec не пересоздаётся заново в сервисе: он уже знает поле, направление и умеет вернуть применённый sort обратно в строку через toRaw().
За счёт этого findPage(...) начинает читаться как сценарий, а не как набор трюков. А это ровно то, что нам нужно в учебном проекте: чтобы вы могли открыть код через месяц и понять, что он делает, без шаманского танца вокруг отладчика.
6. Пример JSON-ответа PagedResponse
Когда вы делаете PagedResponse, полезно один раз увидеть глазами, что именно получает клиент. Это снимает много вопросов уровня “а зачем мы вообще добавляли totalPages?”. Представим, что клиент сделал запрос без параметров, а значит применились defaults: page=0, size=20, sort=updatedAt,desc.
Ответ (упрощённо) может выглядеть так:
// Упрощённый пример: items укорочены для компактности
{
"items": [
{
"id": "c2e2c437-0f6b-4c08-9e5b-8db4bfc2f2df",
"title": "Fix pagination bug",
"status": "IN_PROGRESS",
"priority": "HIGH",
"updatedAt": "2026-03-21T10:15:30Z"
}
],
"page": 0,
"size": 20,
"totalElements": 57,
"totalPages": 3,
"sort": "updatedAt,desc"
}
Здесь важно не количество элементов в примере (у нас просто один для компактности), а то, что ответ сам себя объясняет. Клиент видит, какую страницу он получил, какого размера, сколько всего элементов существует и по какой сортировке он их видит. В итоге UI клиента может спокойно нарисовать пагинацию, кнопки “следующая/предыдущая”, и даже надпись “показано 1–20 из 57”, не додумывая ничего за сервер.
И это как раз то состояние API, к которому мы стремимся: сервер не заставляет клиента угадывать, сервер внятно сообщает контекст выборки. Если бы API мог говорить, он бы сказал: “да, я взрослый, я знаю, что ты будешь с этим делать”.
7. Типичные ошибки ручной пагинации
С ручной пагинацией есть хорошая новость: ошибок не так уж много. Плохая новость: почти все они выглядят как “мелочь”, но ломают контракт очень неприятно. Поэтому лучше один раз проговорить их словами, чем потом три раза ловить в логах и делать вид, что вы так и задумали.
Ошибка №1: нарезать страницу до сортировки.
Это выглядит логично, если думать “мне нужна страница, значит сначала беру 20 элементов, потом сортирую”. Но для клиента это превращается в хаос: элементы будут перескакивать между страницами, один и тот же объект может внезапно появиться и на первой, и на второй странице при одинаковом sort. Правильный порядок для списка почти всегда такой: сначала сортируем всю выборку, потом делаем slice.
Ошибка №2: считать totalElements по размеру items.
На первой странице items.size() может быть 20, и кажется, что “ну вот и total”. Но как только появится вторая страница, выяснится, что total “прыгает” и клиент не понимает, сколько всего данных. totalElements — это информация о всей выборке, а не о текущем куске. Если хочется проверить себя, спросите: “Могу ли я по метаданным понять, есть ли следующая страница?” Если нет — метаданные сломаны.
Ошибка №3: off-by-one в индексах from/to.
Самая классическая история: кто-то решает, что to включительно, и пишет to = from + size - 1. Потом передаёт это в subList(from, to) и получает страницу на 19 элементов вместо 20. Или, наоборот, вылетает с ошибкой на последнем элементе. subList(from, to) в Java работает по схеме [from, to). Если вы запомните только одну вещь из этой лекции, пусть это будет она.
Ошибка №4: падать исключением, когда страница “далеко за пределами списка”.
Иногда разработчик видит from >= total и думает: “это же явно ошибка клиента”. А потом выясняется, что клиент просто листает страницы в UI, и в момент, когда данные на сервере изменились (в нашем учебном проекте — хотя бы из-за seed data или действий тестов), UI может запросить “страницу 10”, а элементов стало меньше. В рамках list endpoint’а корректнее вернуть пустой items при валидном запросе, а не падать. Мы этот кейс специально обрабатываем в slice(...).
Ошибка №5: размазать сборку PagedResponse по разным слоям.
Один кусок считает totalPages в контроллере, второй кусок режет список в репозитории, третий кусок собирает sort строкой в сервисе. Потом вы меняете default size и начинаете искать три места, где оно захардкожено. Гораздо спокойнее держать алгоритм list endpoint’а в одном месте, обычно в сервисе, а контроллер оставить как “вход-выход”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ