1. Пагінація як обовʼязок рівня доступу до даних
Коли ви вперше пишете метод репозиторію і отримуєте List<Product>, усе здається чудовим: ось список — бери й радій. Але за кілька днів проєкт починає поводитися як добра людина за шведським столом: «я візьму все». І ось ви випадково читаєте з бази тисячу товарів, потім десять тисяч, потім «ну гаразд, просто на локалці повільно». Пагінація потрібна не тому, що хтось любить кнопки «Наступна сторінка», а тому, що читання даних завжди має бути обмеженим і передбачуваним.
Головний зсув у мисленні тут такий: обмеження результату — частина контракту репозиторію. Це не косметика зверху і не «ну потім у контролері обріжемо». Якщо ви заздалегідь кажете репозиторію: «дай мені тільки 20 елементів», ви економите памʼять JVM, час мережі, ресурси БД і, що особливо приємно, нерви людини, яка це налагоджуватиме.
Щоб не лишатися на рівні абстракцій, давайте згадаємо SQL із перших днів курсу. Пагінація в реляційному світі — це дуже приземлені LIMIT і OFFSET (плюс ORDER BY, інакше це лотерея). Spring Data просто дає зручну Java-форму для передавання цих намірів у запит.
Pageable: як читати список
Якщо ви ще не стикалися з Pageable, він може здатися «ще однією штукою, яку треба запамʼятати». Але насправді це один із найпривітніших API-об’єктів Spring Data: він каже репозиторію три речі — яку сторінку, якого розміру, у якому порядку. І все. Жодних SQL-рядків, жодних ручних LIMIT/OFFSET, жодних танців із «обріжемо список після читання».
Важливо, що Pageable — це саме опис запиту до даних, а не «контейнер результату». Тобто Pageable відповідає на запитання «як читати», а не «що ми прочитали». Саме тому він зʼявляється в параметрах методу репозиторію.
Корисно тримати в голові просту таблицю, що повʼязує Pageable із SQL-еквівалентом:
| Що хочемо контролювати | Що зберігається в Pageable | На що схоже в SQL |
|---|---|---|
| Номер сторінки | pageNumber | OFFSET pageNumber * pageSize |
| Розмір сторінки | pageSize | LIMIT pageSize |
| Порядок | Sort усередині |
ORDER BY ... |
Це всього три пункти, але вони перетворюють «дай усе» на «дай акуратний шматочок, будь ласка». І, так, якщо ви знайомі з масивами в Java, вам це буде або приємно, або навпаки: нумерація сторінок у Pageable починається з нуля. Перша сторінка — це 0. Як і зазвичай у програмуванні: нуль — це не помилка, а спосіб життя.
2. PageRequest: створюємо Pageable
З інтерфейсом Pageable напряму ви зазвичай не працюєте: ви просто створюєте його реалізацію. Найчастіша реалізація для «класичної» пагінації (через offset) — це PageRequest. Він створюється через статичний метод of(...), і в цьому є приємна інженерна чесність: ви явно задаєте page і size, а за бажання додаєте сортування.
Нижче — найпростіший приклад, без сортування. Він корисний для перших кроків, але в реальних списках порядок зазвичай усе-таки потрібен.
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
// Pageable — це "як читати", а не "що прочитали"
Pageable pageable = PageRequest.of(0, 20); // 0 = перша сторінка, 20 = розмір сторінки (кількість сутностей)
Зверніть увагу на дві речі. По-перше, 0 — це «перша сторінка». По-друге, 20 — це «20 рядків результату», а не «20 кілобайт» і не «20 секунд терпіння». Spring Data сприймає size як кількість сутностей у відповіді.
Трохи ближчий до практики приклад — із сортуванням. І тут ми акуратно використовуємо те, що обговорювали в минулій лекції: стабільний порядок, де друге поле (id) допомагає зафіксувати результат, якщо основне поле не є унікальним.
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
// Важливо: при пагінації майже завжди потрібне сортування, інакше сторінки будуть "гуляти"
Pageable pageable = PageRequest.of(
0, 12,
// Стабілізуємо порядок: спочатку за createdAt, а потім за id (на випадок однакових дат)
Sort.by("createdAt").descending().and(Sort.by("id").descending())
);
Цей обʼєкт тепер можна передати в репозиторій, і він уже читатиме дані «шматком», а не всім набором. І так: ви знову використовуєте імена полів entity (createdAt, id), а не created_at і не product_id — ми все ще живемо у світі Spring Data JPA, який дивиться на Java-модель.
3. Pageable у репозиторії
Щоб пагінація справді запрацювала, Pageable має зʼявитися в сигнатурі методу репозиторію. Це виглядає нудно, але саме нудні речі найчастіше й рятують проєкти: сигнатура методу стає контрактом, який не можна «забути», інакше доведеться знову читати все.
Нехай у нас у mini-shop є типовий сценарій каталогу: «отримати товари в категорії, лише активні, посторінково». На рівні derived query це можна сформулювати дуже природно: фільтр за category.code + status, а зверху додати Pageable.
import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Повертаємо Page<Product>, щоб отримати не тільки список, а й метадані сторінки
Page<Product> findByCategoryCodeAndStatus(
String categoryCode,
ProductStatus status,
// Pageable передає в запит розмір/номер сторінки та сортування
Pageable pageable
);
}
Тут є кілька практичних деталей, які новачки зазвичай пропускають.
По‑перше, Pageable майже завжди логічніше ставити останнім параметром: так сигнатура читається як «умови → керування читанням». Spring Data це розуміє і так, але людям читати зручніше саме в такому порядку.
По‑друге, зверніть увагу: ми повертаємо Page<Product>. Це означає, що ми хочемо отримати не тільки список, а й «обгортку результату», яка вміє зберігати інформацію про сторінку. У межах цієї лекції нам важливіше сам факт, що тип результату змінюється, коли ми говоримо про посторінкове читання. Деталі того, які бувають варіанти «обгорток» результату, ми поки не перетворюємо на окрему філософію — нам потрібно просто навчитися правильно передавати Pageable.
По‑третє, метод залишається читабельним. Це прямо хороша новина: пагінація сама по собі не робить derived query «монстром». Монстром його робить спроба втиснути 7 фільтрів і 3 сортування в одне імʼя — а не Pageable.
4. Pageable у SQL: ORDER BY, LIMIT, OFFSET
Коли ви викликаєте метод репозиторію з Pageable, під капотом відбувається те, що ви вже знаєте із SQL, просто в «обгортці фреймворку». Hibernate і Spring Data будують запит, у якому зʼявляться ORDER BY, LIMIT і OFFSET. Навіть якщо ви не пишете SQL вручну, корисно подумки «бачити» його, щоб не ставитися до пагінації як до магії.
Уявімо, що ми запросили сторінку 0, розмір 12, сортування createdAt desc, id desc, категорія "TEA", статус ACTIVE. Уявно це можна записати так:
select ...
from product p
join category c on p.category_id = c.id
where c.code = 'TEA'
and p.status = 'ACTIVE'
-- ORDER BY є обовʼязковим для стабільних сторінок (інакше порядок не гарантується)
order by p.created_at desc, p.id desc
-- LIMIT = розмір сторінки, OFFSET = pageNumber * pageSize
limit 12 offset 0;
А якби ми запросили другу сторінку (pageNumber = 1), офсет став би 12:
select ...
from product p
join category c on p.category_id = c.id
where c.code = 'TEA'
and p.status = 'ACTIVE'
order by p.created_at desc, p.id desc
limit 12 offset 12;
І тут важливо відчути одну річ: пагінація без сортування — це «дай мені випадкові 12 рядків, потім інші випадкові 12 рядків». База даних не зобовʼязана повертати рядки в одному й тому самому порядку, якщо ви не вказали ORDER BY. Іноді здається, що «воно й так по id», але це просто випадковий збіг вашого оточення і даних. Щойно таблиця стане більшою, зʼявляться оновлення, план запиту зміниться — порядок «попливе», і сторінки почнуть перетинатися або пропускати елементи.
Тому практичний висновок простий: якщо ви робите пагінацію, робіть сортування. У реальному проєкті це правило зазвичай живе як негласний закон команди, приблизно на рівні «не пушимо в main код, який падає на старті».
5. Пагінація в CatalogService
Дуже легко зробити репозиторій «розумним», а сервіс залишити «просто проксі». Але на практиці зручніше інакше: репозиторій дає можливість читати, а сервіс формує use case. Це означає, що саме сервіс вирішує, який розмір сторінки є нормальним за замовчуванням, який порядок вважати розумним і як обробити номер сторінки, що прийшов із зовнішнього світу (або навіть просто з вашого CommandLineRunner).
Візьмімо наш CatalogService (який ми вже почали збирати раніше) і додамо туди метод, що читає сторінку каталогу. Тут ми не чіпаємо web-шар і не обговорюємо DTO: просто показуємо, як сервіс перетворює «дай сторінку» на PageRequest.
import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
public Page<Product> getActiveCatalogPage(String categoryCode, int page, int size) {
// Фіксуємо "канонічний" порядок каталогу: спочатку нові
Sort sort = Sort.by("createdAt").descending().and(Sort.by("id").descending());
// Збираємо Pageable прямо в сервісі: це частина сценарію (use case), а не "десь потім"
PageRequest pageable = PageRequest.of(page, size, sort);
// Репозиторій отримує і фільтри, і правила читання (сторінка/розмір/сортування)
return productRepository.findByCategoryCodeAndStatus(categoryCode, ProductStatus.ACTIVE, pageable);
}
Тут одразу кілька речей зроблено правильно, і саме це корисно як звичка.
Сервіс сам задає сортування і не перекладає це рішення на код, який викликає сервіс. Це зручно, тому що use case «каталог» зазвичай передбачає фіксований порядок: наприклад, «спочатку нові», «спочатку дешевші», «спочатку за назвою». Якщо порядок справді має бути динамічним — тоді так, ви винесете Sort назовні. Але за замовчуванням порядок краще фіксувати як частину сценарію.
Другий момент — PageRequest створюється в сервісі, а не «десь потім». Це важливо методично: пагінація — частина use case, а не випадкова надбудова, яка живе то в репозиторії, то в контролері, то в утиліті.
І третій момент — це місце, де зазвичай спливає помилка нумерації сторінок. Людина любить «сторінка 1», а PageRequest любить «сторінка 0». Щоб не влаштовувати вічну плутанину, можна домовитися: усередині застосунку завжди zero-based, а якщо вам колись доведеться приймати номер сторінки «як людина», ви зробите перетворення явно, одним місцем.
Приклад такого перетворення (просто як фрагмент безпечної арифметики):
int requestedPage = 1; // як сказала людина: «перша сторінка»
int page = Math.max(0, requestedPage - 1); // як розуміє PageRequest: 0 (і захист від від’ємних значень)
Так, це виглядає банально. Але саме такі банальні два рядки економлять години налагодження, коли хтось починає скаржитися: «чому перша сторінка порожня?».
6. Мінідемо: читаємо сторінку
Коли ми вивчаємо persistence layer, дуже хочеться «помацати» результат. Поки в нас немає web-шару (і це нормально), можна зробити навчальне мінідемо через CommandLineRunner: запуск застосунку, один виклик сервісу, кілька рядків у консолі. Це не архітектура на роки, але для навчання працює чудово — як ліхтарик у підвалі, де ви ще не ввімкнули світло.
Наприклад, друкуємо SKU та імена товарів із першої сторінки:
import org.springframework.data.domain.Page;
// Запитуємо 0-ту сторінку (тобто "першу"), розміром 3 елементи
Page<Product> page = catalogService.getActiveCatalogPage("TEA", 0, 3);
// getSize() — це розмір сторінки, який ви запросили (а не фактична кількість елементів у таблиці)
System.out.println("Розмір сторінки = " + page.getSize()); // Розмір сторінки = 3
// getContent() — самі елементи поточної сторінки
page.getContent().forEach(p ->
System.out.println(p.getSku() + " -> " + p.getName())
);
// Приклад очікуваного виводу (залежатиме від ваших даних і сортування):
// TEA-001 -> Зелений чай
// TEA-002 -> Чорний чай
// TEA-003 -> Чай улун
Тут важливий саме ефект: ви бачите, що сервіс читає три елементи, хоча в таблиці їх може бути 300. І це відчуття «ми керуємо читанням» — одне з головних у роботі з репозиторіями. Не база командує вам «тримай усе», а ви просите рівно стільки, скільки потрібно сценарію.
7. Типові помилки під час роботи з Pageable
Помилка №1: вважати, що перша сторінка — це 1, і отримувати дивні «порожні» результати.
PageRequest живе у zero-based світі: PageRequest.of(0, 20) — це перша сторінка. Якщо ви передасте 1, ви чесно попросите другу. Ця помилка особливо підступна, тому що код компілюється, працює, і лише потім хтось помічає, що «першу сторінку ми взагалі ніколи не показуємо».
Помилка №2: намагатися передати null замість Pageable, щоб «нехай поверне все».
Такий стиль швидко перетворює репозиторій на непередбачувану штуку: то читаємо по 20, то читаємо «все», а потім дивуємося памʼяті й часу відповіді. Якщо вам потрібне читання без обмежень, краще зробити окремий метод репозиторію без Pageable, щоб контракт був видимим. Але в каталозі й адмінських списках зазвичай краще взагалі не робити «все».
Помилка №3: змінювати сортування між сусідніми сторінками одного й того самого списку.
Посторінкове читання передбачає, що ви гортаєте одну й ту саму впорядковану послідовність. Якщо на першій сторінці сортування за createdAt desc, а на другій раптом за name asc, ви фактично читаєте вже інший список. Результат буде «рваним»: дублікати, пропуски, відчуття, що база вас тролить.
Помилка №4: робити пагінацію без ORDER BY, сподіваючись, що «і так по id».
База даних не зобовʼязана повертати рядки в стабільному порядку без ORDER BY. Іноді здається, що все стабільно, бо таблиця маленька, план запиту простий, а життя ще не встигло вас навчити. Щойно даних стане більше або зʼявляться оновлення, сторінки почнуть гуляти. Якщо ви робите пагінацію, порядок має бути явним.
Помилка №5: брати занадто великий розмір сторінки «про всяк випадок».
Фраза «давайте розмір 10_000, щоб точно вистачило» зазвичай означає «давайте скасуємо сенс пагінації, але назвемо це пагінацією». Розмір сторінки — частина проєктного рішення. Для каталогу часто достатньо 10–50, іноді 100, але величезні розміри майже завжди призводять до зайвого навантаження, навіть якщо запит формально «все ще Page».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ