JavaRush /Курси /Hibernate deep-dive /Проєкційне читання списків

Проєкційне читання списків

Hibernate deep-dive
Рівень 9 , Лекція 4
Відкрита

1. Два backoffice-списки в Commerce Persistence Lab

Projection легко недооцінити, мовляв, «ну так, можна обрізати поля». Але на реальному списку це вже не косметика, а повноцінний рефакторинг із дуже відчутним результатом. Ми розглянемо два типові сценарії: таблицю товарів і таблицю замовлень у backoffice. В обох випадках користувачеві екрана — і коду — потрібні лише кілька колонок, а не весь граф сутностей.

Давайте зафіксуємо ціль максимально приземлено. У нас є два спискові read use case:

Перший — список Product. Зазвичай у таблиці товарів потрібні id, sku, name, інколи статус. Нам не потрібно тягнути ProductDetails, категорії, пов’язані сутності й уже точно не потрібна managed-сутність, яку випадково можна змінити під час «просто читання».

Другий — список PurchaseOrder. У типовій таблиці замовлень потрібні id, orderNumber, статус, дата створення та email клієнта. Зверніть увагу: email зберігається в Customer, тож список замовлень у найпростішому варіанті майже гарантовано означає, що хтось «невинно» звернеться до order.getCustomer().getEmail() і знову отримає або N+1, або серію lazy-завантажень, або спокусу зробити все EAGER, а потім плакати.

Ключовий момент: ми не «додаємо ще один DTO». Ми формуємо read-model під конкретні таблиці та закріплюємо його на рівні репозиторію й query-сервісу.

Щоб думка не розчинилася, можна уявити це так:

flowchart TD
    UI["Екран списку backoffice
(умовна таблиця)"] QS["Query Service
@Transactional(readOnly=true)"] R["Repository
@Query + projection"] DB[(PostgreSQL)] DTO["ProductListRow / OrderListRow
(read-model)"] UI --> QS --> R --> DB DB --> R --> DTO --> QS --> UI

Тут DTO — не «переносний мішок даних». Це контракт «рядок таблиці», а не «об’єкт домену для життя та розвитку».

2. Старт: колонки таблиці та сортування

Дуже хочеться почати рефакторинг з IDE: відкрити репозиторій, замінити тип, що повертається, і гордо натиснути Build. Але якщо так зробити, ви майже гарантовано отримаєте або проєкцію розміром із напівсутність, або три різні варіанти одного й того самого DTO, або вічне «давайте додамо ще кілька полів, раптом знадобляться». Тому стартовий крок — не код, а ясність: які колонки реально потрібні списку сьогодні.

Для списку товарів домовимося, що нам потрібні id, sku, name. Цього достатньо, щоб побачити принцип. Якщо ваш екран у проєкті вже відображає ще статус або ціну — не проблема, додасте пізніше, але додавати варто лише те, що реально відображається.

Для списку замовлень домовимося, що нам потрібні id, orderNumber, customerEmail, status, createdAt. Це вже показує важливу річ: одне поле (customerEmail) лежить в іншій сутності, отже, ми або явно робимо join, або починаємо жити у світі «випадкових догрузок».

Наступний шматок дисципліни — стабільне сортування. Для товарів це зручно робити за name, а потім за id, щоб однакові назви не ламали передбачуваність. Для замовлень часто зручно сортувати за createdAt desc, а потім за id desc, щоб сторінка № 2 не «переїжджала» при появі нових замовлень.

Якщо хочеться запам’ятати це одним реченням, то воно таке: спочатку визначаємо колонки та порядок сортування, потім обираємо projection і лише потім пишемо запит. Інакше ви будуєте API у зворотному напрямку: «ось вам сутність, а ви вже якось з неї витягніть потрібне».

Щоб не збирати кожен список із нуля, зручно тримати короткий workflow:

  1. Зафіксувати колонки та default sort.
  2. Обрати форму read-model: interface-based projection — коли список простий та однотабличний; class-based DTO або явний @Query — коли потрібні join-и, перейменування полів і ви хочете візуально контролювати склад колонок.
  3. Зробити так, щоб репозиторій одразу повертав read-model, а не entity.
  4. Додати Pageable і стабільне сортування через унікальний tie-breaker.
  5. Перевірити SQL trace і count-запит, якщо ви віддаєте Page.

Далі просто пройдемо цей маршрут на Product і PurchaseOrder. Для обох прикладів залишимося в class-based підході: так простіше бачити і форму результату, і самі join-и, а для замовлень це особливо корисно.

3. Product: entity → ProductListRow

На Product цей маршрут майже нудний — і саме в цьому його сила. Список однотабличний та плаский, тож тут підійшла б і interface-based projection. Але щоб далі не розтікатися на дві різні гілки, залишимо class-based select new: він максимально явно показує форму результату і потім без сюрпризів переноситься на PurchaseOrder.

Уявімо, що в репозиторії було щось подібне:

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> {

    // Повертаємо managed entity: усередині транзакції це живий обʼєкт Hibernate
    // з усіма "бонусами" persistence context (dirty checking, випадкові зміни тощо).
    Page<Product> findByStatus(ProductStatus status, Pageable pageable);
}

На перший погляд усе красиво. Але проблема саме в тому, що метод «занадто універсальний». Він повертає managed-entity, а отже всередині @Transactional цей список товарів стає частиною persistence context з усіма його звичками: snapshots, dirty checking, потенційними accidental updates, а ще спокусою витягнути lazy-зв’язки прямо під час рендерингу списку.

Створюємо read-model для рядка списку

Read-model тут навмисно проста: той самий ProductListRow, де є лише id, sku і name. Цієї форми достатньо для рядка каталогу, і вона вже чесно відділяє список від сутності Product.

Додаємо projection-метод у репозиторій

Тепер замінимо «універсальний» entity-метод на чесний read-метод, який повертає Page<ProductListRow>. Ми пишемо явний JPQL-запит із конструкторним виразом, щоб візуально було видно, які поля вибираються.

import com.example.commerce.catalog.dto.ProductListRow;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Query("""
           select new com.example.commerce.catalog.dto.ProductListRow(p.id, p.sku, p.name)
           from Product p
           where p.status = :status
           """)
    // Важливо: запит відповідає за форму результату, а стабільний порядок приходить з Pageable.
    Page<ProductListRow> findProductRowsByStatus(ProductStatus status, Pageable pageable);
}

Тут важливо одразу кілька моментів.

Перший — ми вибрали лише потрібні колонки. Hibernate не буде матеріалізовувати Product як managed-entity, тому що ми не вибираємо p, ми вибираємо new ....

Другий — запит залишився в тому ж class-based стилі, який уже знайомий за select new. Тобто це не новий baseline, а той самий патерн, лише застосований до списку товарів.

Третій — сортування ми не зашиваємо в @Query. Порядок сторінок живе в Pageable, щоб default sort був видимий там, де ми дійсно збираємо список.

Використання через query-сервіс (тонкий шар)

Гарний стиль у проєкті — тримати такі методи не як «просто ще один репозиторій», а як read-use-case на рівні query пакета. Тоді write-сервіс каталогу може залишатися entity-oriented, а read-сервіс — projection-oriented.

import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true) // Декларуємо намір: це читання, без випадкових записів.
public Page<ProductListRow> listActiveProducts(int page, int size) {
    Sort sort = Sort.by("name").ascending()
            .and(Sort.by("id").ascending());

    PageRequest pageable = PageRequest.of(page, size, sort);

    return productRepository.findProductRowsByStatus(ProductStatus.ACTIVE, pageable);
}

Так default sort залишається поруч із read use case, а репозиторій не починає тягнути за собою фіксований order by на всі випадки життя.

SQL після переходу

Якщо до рефакторингу ви робили Page<Product>, Hibernate зазвичай вибирає всі колонки таблиці product (а інколи ще й робить несподівані вторинні запити, якщо десь хтось поліз у lazy).

Після переходу на projection SQL стає «пласким» і чесним: приблизно так (спрощено):

-- Читаємо рівно ті колонки, які потрібні рядку списку.
-- Сортування приходить із Pageable, а не з захардкоженого ORDER BY у запиті.
select p.id, p.sku, p.name
from product p
where p.status = ?
order by p.name, p.id
limit ? offset ?;

І ви прямо очима бачите: «так, я читаю таблицю, так, лише три колонки, так, тільки те, що потрібно списку». А поруч Spring Data зробить очікуваний count. У цей момент список читається передбачувано: лише потрібні колонки, один content-query плюс count, без зайвого managed-життя навколо нього.

4. PurchaseOrder: список з email клієнта

Якщо список товарів був «розминкою», то список замовлень — уже ближче до реальності. Тут дуже легко випадково опинитися в ситуації «я читаю замовлення, а потім ще окремо читаю клієнтів, а потім ще окремо…». Саме цей список часто народжує класичний N+1: ми показали 20 замовлень і на кожному виводимо email клієнта — і ось уже +20 запитів без жодного злого наміру, просто тому, що код «виглядає нормально».

Ми зробимо read-model, який одразу містить customerEmail, тобто те, що реально потрібне таблиці, і отримаємо це одним запитом через явний join.

DTO/record для рядка списку замовлень

Read-model тут трохи багатша: OrderListRow(id, orderNumber, customerEmail, status, createdAt). Важлива не довжина результату, а те, що customerEmail приходить як частина рядка списку, а не як привід потім іти в o.getCustomer().

Репозиторій: join, Pageable і countQuery

Пишемо query-метод. Тепер новою відмінністю порівняно з Product стає не сама projection, а явний join на Customer і явний countQuery для Page.

import com.example.commerce.orders.dto.OrderListRow;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;

public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {

    @Query(
        value = """
               select new com.example.commerce.orders.dto.OrderListRow(
                   o.id, o.orderNumber, c.email, o.status, o.createdAt
               )
               from PurchaseOrder o
               join o.customer c
               """,
        countQuery = """
               select count(o)
               from PurchaseOrder o
               join o.customer c
               """
    )
    Page<OrderListRow> findOrderRows(Pageable pageable);
}

Тут countQuery показано явно не заради ритуалу. У Page є два різні питання: які рядки показати зараз і скільки рядків усього існує. Щойно join-ів і умов стає більше, явний countQuery знімає зайву магію та робить цей контракт прозорішим.

І саме тут видно, чому class-based DTO залишається хорошим вибором для такого списку: join і склад колонок прописані прямо в запиті, а не ховаються за «спочатку повернемо PurchaseOrder, потім якось дістанемо email».

Read-only service і Pageable

Сервіс залишається дуже тонким: він задає стабільний default sort і передає його в репозиторій.

import com.example.commerce.orders.dto.OrderListRow;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
public Page<OrderListRow> listOrders(int page, int size) {
    Sort sort = Sort.by("createdAt").descending()
            .and(Sort.by("id").descending());

    PageRequest pageable = PageRequest.of(page, size, sort);

    return purchaseOrderRepository.findOrderRows(pageable);
}

Так порядок сторінок залишається там, де й має бути для спискового use case: у Pageable, а запит відповідає лише за форму результату та join-и.

Projection замість JOIN FETCH у списку

Тут корисно усвідомити тонкий момент, який часто плутають після дня про fetch-інструменти. На списку замовлень можна було б вирішити проблему email клієнта JOIN FETCH-ом, тобто все одно завантажувати managed PurchaseOrder і заодно Customer. У результаті у вас з’являється «живий граф» там, де потрібен плаский read-result.

Projection вирішує задачу інакше: join потрібен лише для колонки customerEmail, а не для завантаження графа сутностей. Тобто це не конкурент fetch-інструментам, а часто більш правильний крок на рівні вибору read-model.

5. Read-model у сервісах і транзакціях

Коли ви вперше переводите список на projection, виникає типова внутрішня розмова розробника: «А як тепер редагувати товар, якщо список не повертає Product?». Це рівно той момент, коли ми розуміємо: список і редагування — різні use case. Список повертає рядок таблиці. Редагування повертає entity або завантажує entity всередині write-сценарію.

У нашому проєкті це можна тримати дуже просто: write-сервіси залишаються entity-oriented, а query-сервіси стають projection-oriented. У пакеті catalog.service живуть методи створення та оновлення товару, які працюють за патерном find + mutate, керують станами та спираються на managed-семантику. У пакеті catalog.query живе читання списку у вигляді Page<ProductListRow>. Аналогічно для замовлень.

Із транзакцією теж стає простіше, а не складніше. Результат projection — не managed. Він не зберігається в persistence context. Йому не потрібно «жити» у відкритій сесії. Це означає, що ви менше залежите від меж транзакції і рідше ризикуєте зловити LazyInitializationException через те, що десь пізніше хтось полізе в асоціації. У ProductListRow немає асоціацій — максимум ви звернетеся до row.name(), і це просто рядок, а не тригер SQL.

Нарешті, важливе правило — не стріляйте собі в ногу, навіть якщо нога гарна: projection не повинна використовуватися для запису. Не треба намагатися «зберегти список» або «оновити статус замовлення через DTO». Write-сценарій має залишатися write-сценарієм: завантажили managed entity (або отримали reference), змінили, зафіксували. Projection — це read-only контракт. Її сила якраз у тому, що вона не вдає, ніби вміє бути всім одразу.

6. Перевірка за SQL trace

Є небезпечна ілюзія: «я замінив List<Product> на List<ProductListRow>, отже зробив швидше». У світі Hibernate це не працює. У нас уже є головний інструмент курсу — SQL trace і статистика. І хороший рефакторинг списків обов’язково треба перевіряти очима: які запити пішли в БД, скільки їх, наскільки вони широкі.

До рефакторингу список замовлень дуже легко виглядав так: один запит на замовлення, а потім ще пачка запитів на клієнта (якщо десь читається email). У SQL trace це зазвичай видно як однакові select ... from customer where id = ?, що повторюються багато разів. І так, код може виглядати невинно, особливо якщо це «лише логування» або «лише мапінг у DTO».

Після переходу на projection по замовленнях картина стає нудною — а це комплімент. Ви побачите один запит, у якому одразу є join на клієнта, і більше жодних сюрпризів з email. Для Page додасться count, але це очікувано й контрольовано. І найприємніше: ширина select стане меншою. У список підуть рівно ті колонки, які ви обрали. Не 20 полів замовлення, не весь клієнт, а лише потрібні 4–5 значень.

Якщо ввімкнено профіль sql-trace, дуже корисно привчити себе до короткого ритуалу після таких правок: запускаємо сценарій списку, дивимося SQL, переконуємося, що запит один (або два для Page), переконуємося, що немає вторинних select на lazy-зв’язки, переконуємося, що select не тягне зайвого. Це займає хвилину, але економить години розборів «а чому воно гальмує на проді, хоча в мене на ноутбуці все літає».

7. Типові помилки

Помилка № 1: «косметичний» рефакторинг без зміни типу результату.
Коли ви переводите списки на projection, найчастіша помилка — зробити «косметичний» рефакторинг: перейменувати метод у findProductRows..., але залишити Page<Product>. Зовні ніби стало красивіше, але по суті ви все ще повертаєте managed entity з усіма її побічними ефектами. Тому перевіряйте не ім’я, а тип результату і форму select.

Помилка № 2: «універсальна» проекція на всі випадки життя.
Ще одна поширена помилка — спробувати зробити «універсальну» проекцію, яка підходить і для списку, і для картки, і для редагування, і «про всяк випадок» ще включає пару полів із пов’язаних сутностей. Зазвичай така проекція швидко розростається до напівentity, і ви повертаєтеся до тієї ж проблеми: надто широкий контракт і занадто багато даних у кожному читанні. Дисципліна тут проста: один use case — один projection-контракт.

Помилка № 3: переплутано порядок аргументів у select new ....
Часто ламаються і більш технічні деталі. Наприклад, для class-based projection легко переплутати порядок аргументів у select new ... і в конструкторі (або canonical-конструкторі record). Код компілюється, застосунок стартує, а потім у списку раптом sku виявляється в полі name. Це неприємна помилка, бо вона не завжди падає винятком — вона просто тихо робить дані неправильними. Лікується уважністю і звичкою тримати select та конструктор поруч і читати їх зліва направо.

Помилка № 4: нестабільне сортування в pageable-списках.
Окрема біль — нестабільне сортування в pageable-списках. Ви сортуєте товари лише за name, не додаєте id, і в якийсь момент користувач гортає сторінки та бачить «стрибучі» рядки. Це не баг Hibernate, це ваша недомовленість: для однакових name порядок не визначений. Тому в списках майже завжди потрібен унікальний tie-breaker, найчастіше id.

Помилка № 5: «оптимізація count» без розуміння, що Page — це два запити.
Нарешті, багато хто дивується, що Page робить два запити, і починає «оптимізувати count», не розуміючи, що проблема взагалі не там. У реальному проєкті count справді може стати дорогим, але спочатку важливіше інше: розуміти, що pageable-сценарій — це два запити, і вміти читати їх у SQL trace. Коли ви це розумієте, ви перестаєте містично боятися pagination і починаєте проєктувати її свідомо.

1
Опитування
Проєкції запитів, рівень 9, лекція 4
Недоступний
Проєкції запитів
DTO та пагінація
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ