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

Інтерфейсна проєкція

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

1. Вступ

Коли ви починаєте працювати з проєкціями, мозок за звичкою намагається пояснити їх через стару модель: «є сутність Product, а проєкція — це наче Product, тільки без зайвих полів». Це схоже на правду, але лише до того моменту, поки ви не натрапите на перший неочікуваний null через неправильний псевдонім або не зрозумієте, що це взагалі не entity і не живе в persistence context. Тому почнімо чесно: interface-based projection — це контракт, який описує «як виглядає рядок результату», і Spring Data вміє зібрати під цей контракт результат запиту.

Суть проста: ви пишете інтерфейс із гетерами, а Spring Data повертає вам об’єкт, який цей інтерфейс реалізує. Найчастіше всередині це динамічний проксі, який зберігає значення полів (наприклад, із Tuple) і віддає їх через ваші get...().

Ключовий момент: це read-model. У нього немає життєвого циклу entity, немає dirty checking, немає managed/detached драматургії. Це як чекліст покупок: у ньому написано «молоко, хліб, кава», але він від цього не перетворюється на холодильник.

Приклад інтерфейсу для рядка списку товарів у нашому Commerce Persistence Lab:

import com.example.commerce.catalog.entity.ProductStatus;

public interface ProductListView {

    // Ідентифікатор сутності: потрібен для навігації (перейти до картки, відкрити деталі тощо)
    Long getId();

    // Бізнес-ключ / артикул: часто використовують у UI-списках і фільтрах
    String getSku();

    // Людиночитна назва для списку
    String getName();

    // Статус потрібен, щоб швидко підсвітити або відфільтрувати рядки (ACTIVE, ARCHIVED тощо)
    ProductStatus getStatus();
}

Зверніть увагу на дизайн: тут немає «усієї сутності», немає price, немає details, немає категорій. Це саме «рядок списку», а не «картка товару» і вже точно не «об’єкт, який ми зараз підемо змінювати».

Тепер репозиторій може повернути одразу список проєкцій:

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import com.example.commerce.catalog.dto.ProductListView;
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Spring Data бачить повертальний тип-проєкцію і підбирає SELECT під набір гетерів інтерфейсу.
    // Важливо: сортування в імені методу робить порядок результату передбачуваним без ручного Sort.
    List<ProductListView> findByStatusOrderByNameAscIdAsc(ProductStatus status);
}

Тут Spring Data бачить: «повертальний тип — не Product, а проєкція». І далі він намагається побудувати запит так, щоб вибрати тільки потрібні поля. Це і є основна «магія» interface-based підходу: ви задаєте форму результату, і Spring Data підлаштовує читання.

2. Механіка збирання інтерфейсних проєкцій

Якщо class-based DTO projection ми явно збирали через select new ..., то інтерфейсна проєкція здається «занадто простою». Це нормально: мозок програміста не довіряє речам, які виглядають як «написав п’ять рядків — і запрацювало». Але важливо розуміти, що магія не безкоштовна: вона працює за правилами, і ці правила треба знати. Інакше ви довго дивитиметеся на SQL-лог, як кіт на зачинені двері.

У типовому випадку Spring Data робить приблизно такий ланцюжок: він аналізує методи інтерфейсу (getId(), getSku(), getName()), перетворює їх на набір «потрібних властивостей», а потім будує запит, який повертає не entity, а «набір колонок». Далі він загортає ці колонки в проксі, який реалізує ваш інтерфейс.

Схематично це можна подати так:

flowchart TD
    A["Ви: метод репозиторію повертає ProductListView"] --> B["Spring Data: читає гетери інтерфейсу"]
    B --> C["Будує SELECT лише потрібних колонок"]
    C --> D["Hibernate: виконує SQL"]
    D --> E["Spring Data: мапить колонки в проксі-об’єкт"]
    E --> F["Ви: викликаєте view.getSku(), view.getName()"]

З погляду Hibernate це зазвичай означає вужчий SELECT. А раз SELECT уже простіший, ви менше ризикуєте випадково притягнути граф сутностей, отримати зайві join-и або просто забити мережу колонками, які списку не потрібні.

Є дуже важлива межа, яку потрібно тримати в голові: interface-based projection майже ідеальна, коли вам потрібен простий плаский контракт. Якщо ви почнете впихати туди обчислення, умовні значення, форматування, «а давайте склеїмо рядок гарно», ви непомітно опинитеся у світі open projections і SpEL, де Spring Data може бути змушений підтягувати цілу entity (а отже — ви знову платите за entity-loading). Сьогодні ми це не розбираємо глибоко, але логіка така: чим більше проєкція схожа на «міні-бізнес-об’єкт», тим сильніше ви тиснете на Spring Data, щоб він робив не те, для чого проєкцію взагалі створювали.

Ще одна обережна думка: interface projection — це не гарантія «один запит і готово», якщо ви почнете лізти в колекції або намагатися в списку читати to-many. Проєкція рятує вас від managed-графа, але не рятує від поганого дизайну читання. У списку краще триматися «тонких» даних: ідентифікатор, бізнес-ключ, статус, пара рядків.

3. Дані зі зв’язків без N+1

Списки майже завжди хочуть дані «не тільки з однієї таблиці». Класичний приклад: список замовлень (PurchaseOrder) і колонка «email клієнта». Наївний шлях — повернути List<PurchaseOrder>, а потім у циклі читати order.getCustomer().getEmail(). Ви вже знаєте, чим це зазвичай закінчується: або N+1, або спробами лікувати це JOIN FETCH на весь список, або нервовим тиком.

З проєкціями ситуація цікавіша: ви хочете лишитися в read-model, але все ж витягти поле зі зв’язаної сутності. І тут у вас є два базові стилі.

Перший стиль — вкладена проєкція. Ви не «пласко» тягнете email, а кажете: «у результаті є шматок customer, і з нього потрібен email». Це виглядає так:

import com.example.commerce.orders.entity.PurchaseOrderStatus;

public interface OrderListView {

    // Ідентифікатор замовлення для переходу в деталі
    Long getId();

    // Номер замовлення для відображення в списку
    String getOrderNumber();

    // Статус замовлення (наприклад, NEW/PAID/SHIPPED)
    PurchaseOrderStatus getStatus();

    // Вкладена проєкція: назовні віддаємо лише шматок read-model для Customer,
    // а не весь managed Customer.
    CustomerEmailView getCustomer();
}

А інтерфейс для customer має такий вигляд:

public interface CustomerEmailView {

    // Мінімальний контракт: у списку потрібен лише email
    String getEmail();
}

Тоді ваш код читання виглядатиме так:

String email = view.getCustomer().getEmail();

За формою результату це безпечно: назовні все одно виходить read-model, а не managed Customer. Але для joined/nested властивостей SQL уже не такий прозорий, як у пласкому @Query: Spring Data більше роботи робить за вас. Якщо для списку критично візуально контролювати join-и та склад колонок, спокійніше брати flat-проєкцію з псевдонімами або class-based DTO.

Такий nested-варіант чесно відображає структуру домену, але для адмінського списку інколи хочеться більш плаского контракту, щоб не писати getCustomer().getEmail() у кожному місці.

Другий стиль — пласка проєкція через псевдоніми в @Query. Тобто ви явно кажете: «у результаті є колонка customerEmail», і інтерфейс має гетер getCustomerEmail().

Інтерфейс:

import com.example.commerce.orders.entity.PurchaseOrderStatus;

public interface OrderListRowView {

    // Ідентифікатор рядка результату (у цьому випадку id замовлення)
    Long getId();

    // Номер замовлення (те, що показуємо користувачеві)
    String getOrderNumber();

    // Статус замовлення
    PurchaseOrderStatus getStatus();

    // Пласке поле зі зв’язаної сутності (Customer.email) — важливий точний псевдонім у запиті
    String getCustomerEmail();
}

Репозиторій із явним @Query та псевдонімами:

import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import com.example.commerce.orders.dto.OrderListRowView;
import com.example.commerce.orders.entity.PurchaseOrder;

public interface OrderQueryRepository extends Repository<PurchaseOrder, Long> {

    // Важливо: псевдоніми (as ...) мають збігатися з «іменами властивостей» проєкції.
    // Наприклад: customerEmail <-> getCustomerEmail()
    @Query("""
           select o.id as id,
                  o.orderNumber as orderNumber,
                  o.status as status,
                  c.email as customerEmail
           from PurchaseOrder o join o.customer c
           order by o.id desc
           """)
    List<OrderListRowView> findOrderRows();
}

Тут є важлива звичка, яку варто виробити: коли ви робите пласку interface projection із кількох таблиць, псевдоніми мають збігатися з «іменами властивостей» в інтерфейсі. Тобто customerEmailgetCustomerEmail(). Якщо ви назвете псевдонім customer_email, Spring Data не зможе нормально зіставити це з гетером, і ви отримаєте або null, або виняток, або «дуже дивну поведінку» — а це найгірша категорія багів, бо вона звучить як містика.

Чому це добрий стиль для списку? Тому що ви явно робите join у запиті та отримуєте дані одним читанням, без managed-графа. Якщо дивитися на SQL логічно, Hibernate сформує один SELECT із join-ом, а не пачку «дочитувань».

І тут важливе педагогічне зауваження: це не JOIN FETCH. Це звичайний join у JPQL, тому що ми не завантажуємо entity-граф; ми збираємо read-result.

4. Вибір: interface vs class-based

Коли у вас є два інструменти — interface-based і class-based projections — з’являється спокуса вибрати один і обожнити. Програмісти взагалі люблять релігії: EAGER vs LAZY, tabs vs spaces, а тепер ще й DTO vs interface. Давайте без цього. Ми робимо Hibernate deep-dive курс, а отже наша мета — передбачуваність і контроль: за кодом і SQL має бути зрозуміло, що саме відбувається і навіщо.

Щоб обрати підхід усвідомлено, корисно порівняти їх не за «скільки рядків коду», а за тим, наскільки вони прозорі, безпечні під час рефакторингу та зручні як контракт.

Критерій Interface-based projection Class-based DTO projection
Швидкість старту Дуже висока: 1 інтерфейс + метод Трохи повільніше: DTO-клас + select new
Прозорість запиту Висока в derived-методах, але ви не бачите SELECT прямо Максимальна: ви явно пишете select new ...
Контроль імен полів Потребує акуратних імен гетерів/псевдонімів Контроль через конструктор і параметри
Рефакторинг Перейменували гетер/псевдонім — можна тихо зламати Перейменування частіше ламає компіляцію або конструктор, і помилку видно швидше
Плаский результат із кількох сутностей Можливий, але потрібен @Query із псевдонімами Дуже природно через select new
Вбудована логіка/валідація Не місце для логіки, якщо не лізти в open projections Можна додати прості методи, інваріанти, форматування (але обережно)
Супровід Чудово для невеликих списків Чудово для складних read-case і явного контракту

Якби я формулював правило для початківців — і для себе в п’ятницю ввечері, коли мозок уже втомився, — воно було б таким: якщо список дуже простий і ви впевнені, що поля збігаються з іменами в моделі, interface-based projection чудова. Якщо список трохи складніший за звичайний, ви об’єднуєте дані, перейменовуєте колонки, хочете максимально явний контракт і зрозумілий запит, class-based DTO projection зазвичай спокійніша для супроводу.

І ще один важливий практичний критерій: як це читатиметься на code review. Коли колега відкриває репозиторій і бачить @Query("select new ..."), він одразу розуміє, які дані читаються. Коли він бачить List<ProductListView> findByStatus..., він має подумки відновити, які поля є у ProductListView, а потім здогадатися, який SELECT вийде. Це не погано, але для складних сценаріїв іноді хочеться менше ментальної гімнастики.

5. Міні-лабораторія

ProductListView для каталожного списку

Зараз добрий момент приземлити все в звичайний application-код: не вигадувати новий вид проєкцій, а вбудувати вже обраний контракт у read-service. Для каталожного списку у нас уже є ProductListView і derived-query findByStatusOrderByNameAscIdAsc(...). Отже, у збиранні сценарію залишаються дві речі: тримати читання окремо від write-сервісів і явно позначити його як readOnly.

import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.example.commerce.catalog.dto.ProductListView;
import com.example.commerce.catalog.entity.ProductStatus;

@Service
public class ProductQueryService {

    private final ProductRepository productRepository;

    public ProductQueryService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional(readOnly = true)
    public List<ProductListView> findActiveProducts() {
        return productRepository.findByStatusOrderByNameAscIdAsc(ProductStatus.ACTIVE);
    }
}

Якщо запустити такий сценарій із sql-trace, ви побачите, що список лишається тонким: назовні виходить read-model, а не managed Product. І це головна практична цінність interface-based projection на простому списку з однієї сутності: мінімум ceremony, без повернення до List<Product>.

OrderListRowView для списку замовлень із email клієнта

На списку замовлень змінюється не форма збирання, а складність самого читання. Контракт OrderListRowView уже робить результат пласким, а alias-based @Query із customerEmail розв’язує проблему поля зі зв’язаної сутності. В application-коді нам знову потрібен лише тонкий read-service:

import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderQueryService {

    private final OrderQueryRepository orderQueryRepository;

    public OrderQueryService(OrderQueryRepository orderQueryRepository) {
        this.orderQueryRepository = orderQueryRepository;
    }

    @Transactional(readOnly = true)
    public List<OrderListRowView> listOrders() {
        return orderQueryRepository.findOrderRows();
    }
}

Якщо порівняти це з варіантом «повернути List<PurchaseOrder> і потім читати email клієнта в циклі», видно, що дорога до N+1 закрита одразу. Email приходить тим самим запитом, а назовні все одно виходить read-model, а не entity-граф.

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

Помилка №1: сприймати interface-based projection як entity і намагатися «зберегти зміни».
Іноді студенти отримують ProductListView, бачать getName() і думають: «О, зараз я зроблю setName». А setName там немає — і це добре. Проєкція не повинна бути write-model. Якщо вам треба оновити товар, коректний сценарій майже завжди виглядає так: «прочитати id зі списку → в окремому write-use-case зробити find + mutate».

Помилка №2: не стежити за псевдонімами в @Query та отримати загадкові null.
Інтерфейсна проєкція дуже чутлива до відповідності імен. Якщо гетер називається getCustomerEmail(), псевдонім має виглядати як customerEmail. Коли в запиті випадково з’являється customer_email або email, результат може стати null, і ви довго думатимете, що «Hibernate щось не так мапить». Насправді проблема в контракті: Spring Data просто не зміг зіставити колонку й метод.

Помилка №3: намагатися зробити «універсальну проєкцію на всі випадки життя».
Щойно з’являється спокуса створити ProductView на 25 полів «щоб покрити і список, і картку, і експорт в Excel», ви повертаєтеся до тієї самої проблеми, з якої почали рівень: тягнете зайві дані й розмиваєте межі. Проєкція має бути маленькою і чесною: один read-use-case — один контракт.

Помилка №4: випадково протягнути to-many у спискову проєкцію.
Іноді хочеться додати в проєкцію List<OrderItem> getItems() або Set<Category> getCategories() — «ну щоб у таблиці одразу все було». Майже завжди це призводить до двох поганих наслідків: або ви провокуєте величезні join-и й дублікати рядків, або отримуєте додаткове читання, дуже схоже на N+1, тільки тепер воно замасковане під «а я ж не entity повертав». Для списків тримайте контракти пласкими.

Помилка №5: обирати інтерфейс лише тому, що він коротший, і втратити прозорість запиту.
Інтерфейсна проєкція справді економить рядки коду, але іноді ціною того, що читати й обговорювати запит складніше. Якщо read-case починає включати перейменування, умови, складені поля або дані з кількох сутностей, class-based DTO projection часто дає спокійніший і передбачуваніший результат. Тут немає правильного «завжди»: правильна відповідь — та, яку ви можете пояснити за SQL-логом і захистити на code review.

1
Задача
Hibernate deep-dive, 9 рівень, 2 лекція
Недоступна
Interface-based projection для списку товарів
Interface-based projection для списку товарів
1
Задача
Hibernate deep-dive, 9 рівень, 2 лекція
Недоступна
Interface-based projection з аліасом customerEmail
Interface-based projection з аліасом customerEmail
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ