1. Коли потрібен @EntityGraph замість join fetch
Коли ви вперше долаєте N+1 через join fetch, виникає відчуття: «Ну все, життя вдалося, можна ставити join fetch усюди й жити щасливо». А потім ви ловите себе на тому, що навіть найбанальніший findById раптом перетворюється на громіздкий JPQL-запит, а репозиторій починає скидатися на збірник заклинань. @EntityGraph потрібен саме тоді, коли запит простий, а план завантаження — ні.
Уявіть собі типовий сценарій нашого shop-data-jpa. Ми хочемо показати «картку товару»: поля товару (sku, name, price, status) і назву категорії. Мапінг у нас правильний і обережний: Product -> Category найімовірніше LAZY (бо категорія потрібна не завжди, і точно не хочеться вантажити її без потреби).
Якщо ми зробимо:
productRepository.findById(id)
то зв’язок category може бути не завантажений, і при першому зверненні до product.getCategory() Hibernate надішле окремий SELECT. Для одного товару це ще терпимо, але для списків — це типова дорога в N+1.
Ми можемо розвʼязати проблему через join fetch і написати JPQL-запит. Але тут з’являється дуже практична незручність: критерій «за id» і так уже ідеально виражається стандартним методом репозиторію або похідним запитом. Не хочеться перетворювати простий метод на кастомний запит лише заради того, щоб підтягнути категорію.
І тут корисно розділити дві думки.
- Перша думка: логіка вибірки — це «кого» ми шукаємо (за id, за sku, за статусом).
- Друга думка: план завантаження — це «що має бути готове» в знайденого об’єкта до завершення сервісного методу (категорія, позиції замовлення, товар у позиції замовлення тощо).
@EntityGraph — це спосіб зробити другу думку видимою й керованою, не переписуючи першу.
2. Що робить @EntityGraph
Коли ви бачите слово “graph”, мозок новачка іноді малює щось страшне: “граф сутностей”, “граф залежностей”, “граф проблем у моєму житті”. На практиці все простіше: entity graph — це список зв’язків, які потрібно завантажити відразу, у межах одного читання. @EntityGraph у Spring Data JPA — це анотація, якою ми кажемо: «Для цього методу репозиторію завантаж ось ці зв’язки заздалегідь».
Найважливіше: @EntityGraph не змінює зміст запиту. Він не додає фільтри, не «підправляє» WHERE, не перетворює findByStatus на “findByStatusAndSomethingElse”. Він впливає лише на те, які зв’язки будуть ініціалізовані в межах одного читання.
Щоб краще це відчути, зручно тримати в голові такий конвеєр:
flowchart TD
S["Сервісний read-use-case"] --> R["Метод репозиторію"]
R --> Q["Логіка запиту: WHERE / ORDER BY"]
R --> F["Fetch-план: які зв'язки завантажити"]
Q --> JPA["Провайдер JPA: Hibernate"]
F --> JPA
JPA --> SQL["Згенерований SQL"]
SQL --> PC["Persistence Context"]
Логіка запиту відповідає на питання «які рядки або сутності мені потрібні». Fetch-план відповідає на питання «які пов’язані дані мають бути завантажені разом із ними».
attributePaths: як ми описуємо, що вантажити
В анотації @EntityGraph є ключовий параметр attributePaths. Це масив рядків — шляхів до зв’язків. Шлях пишеться так, як ви зверталися б до полів у Java, але рядком: "category", "items", "items.product".
Звучить просто, але в цьому і сила, і небезпека. Сила — тому що ви прямо на методі вказуєте потрібне завантаження. Небезпека — тому що дуже легко почати писати «про всяк випадок» "items.product.category.manufacturer.someOtherThing" і отримати зайве завантаження, яке раптово стане вашим новим «чому все так повільно».
Декларативність без магії
join fetch — це частина JPQL, ви явно пишете «як» хочете завантажити.
@EntityGraph — це декларація «що» ви хочете отримати ініціалізованим. Hibernate (як провайдер JPA) під капотом зазвичай реалізує це через join fetch (особливо для to-one зв’язків), але важливе в навчальному сенсі ось що: ви відділяєте план завантаження від тексту запиту.
3. Базовий приклад: картка товару Product разом із Category без JPQL
Коли ми говоримо «картка товару», ми зазвичай маємо на увазі use-case: отримати один товар і відразу мати доступ до його категорії в межах того самого сценарію читання. Це ідеальний сценарій для @EntityGraph, тому що критерій вибірки простий (за id), а потрібний зв’язок зрозумілий (category).
Спочатку нагадаю, як виглядає мапінг (спрощено). Зверніть увагу на LAZY: це наш default, а не «потім розберемося».
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
public class Product {
// Важливо: LAZY — це дефолтна «гігієна», щоб не тягнути категорію під час кожного читання товару.
@ManyToOne(fetch = FetchType.LAZY)
// Явно фіксуємо FK-колонку, щоб було зрозуміло, звідки береться зв'язок на рівні БД.
@JoinColumn(name = "category_id", nullable = false)
private Category category;
}
Тепер — репозиторій. Ми не хочемо писати JPQL, бо критерій «за id» уже чудово виражається похідним запитом. Зробимо окремий «говорящий» метод саме під картку й анотуємо його графом.
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Під час читання картки продукту наперед ініціалізуй зв'язок category.
@EntityGraph(attributePaths = "category")
Optional<Product> findCardById(Long id);
}
Тут важливий момент, який часто дивує новачків: частина імені до By може бути довільною, а Spring Data все одно розбере критерій за частиною після By. Метод findCardById за змістом так само шукає за id, просто назва чесно підказує, що це читання під картку.
Тепер — сервісний метод читання. Нам важливо, щоб use-case жив у транзакції, хай і read-only, і щоб назовні не «витікало» незаплановане lazy-читання.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogQueryService {
private final ProductRepository productRepository;
public CatalogQueryService(ProductRepository productRepository) {
// Впроваджуємо репозиторій як залежність: сценарій читання керує даними, а не контролер.
this.productRepository = productRepository;
}
@Transactional(readOnly = true) // Важливо: усе читання відбувається всередині persistence context.
public String loadProductCategoryName(Long id) {
// Використовуємо метод з entity graph: очікуємо, що category уже буде ініціалізована.
Product product = productRepository.findCardById(id).orElseThrow();
// Без графа тут міг би «вистрілити» зайвий SELECT через лінивий зв'язок.
return product.getCategory().getName();
}
}
Що ми виграємо? В ідеальному світі — і в нашому навчальному світі з Hibernate 7 це якраз типово — ви побачите в SQL-логах один запит із join до таблиці категорій, а не «спочатку товар, потім окремий select за категорією».
Приблизний силует SQL буде схожий на:
select p.*, c.*
from product p
join category c on c.id = p.category_id
where p.id = ?
Будь ласка, не сприймайте це як «обов’язковий текст SQL» — конкретні псевдоніми й список колонок будуть іншими. Важливо, що категорія завантажується одразу і product.getCategory().getName() не стріляє додатковим запитом.
4. Вкладені attributePaths: картка замовлення CustomerOrder
Якщо приклад із товаром здається занадто «м’яким», то приклад із замовленням зазвичай одразу показує, навіщо взагалі потрібен свідомий fetch-plan. Замовлення (CustomerOrder) саме по собі майже ніколи не цікаве без позицій (OrderItem). А позиція замовлення часто потрібна разом із товаром (product). Тобто картка замовлення — це не один об’єкт, а маленький, але конкретний граф.
Почнемо з очевидного: зв’язки за замовчуванням залишаються LAZY. Замовлення може мати багато позицій, і ми точно не хочемо, щоб кожне читання замовлення автоматично тягнуло їх, а потім ще товари, а потім категорії, а потім виробників, а потім… ви зрозуміли.
import jakarta.persistence.FetchType;
import jakarta.persistence.OneToMany;
public class CustomerOrder {
// Колекція — потенційно «важкий» зв'язок, тому за замовчуванням тримаємо LAZY.
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private java.util.List<OrderItem> items;
}
Тепер у репозиторії ми заводимо метод «картки замовлення» й указуємо граф. Тут ключовий момент: вкладені шляхи.
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface CustomerOrderRepository extends JpaRepository<CustomerOrder, Long> {
// Ініціалізуємо не лише items, а й вкладений зв'язок product всередині кожної позиції.
// Це і є «картка замовлення»: дані готові в межах одного читання.
@EntityGraph(attributePaths = {"items", "items.product"})
Optional<CustomerOrder> findCardByOrderNumber(String orderNumber);
}
Сенс attributePaths тут такий: спочатку ініціалізуй колекцію items, а для кожного OrderItem ініціалізуй product.
І так, це вже to-many завантаження. А отже, у нього є ціна. У більшості випадків Hibernate реалізує це через join-и, і ви отримуєте «широкий» результат, де один рядок замовлення множиться на кількість позицій на рівні рядків SQL. У persistence context Hibernate потім акуратно збирає це назад в один об’єкт CustomerOrder із колекцією items.
Чому це нормально саме для картки замовлення? Тому що картка — це одне замовлення, тобто розмір результату обмежений. У замовлення може бути 3 позиції або 20 — але це все ще «людський» обсяг, який ми можемо дозволити собі одним читанням.
Сервісний метод для такого read-use-case виглядає максимально нудно, і це добре: нудний код часто є найправильнішим.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderQueryService {
private final CustomerOrderRepository orderRepository;
public OrderQueryService(CustomerOrderRepository orderRepository) {
// Репозиторій інкапсулює доступ до даних, сервіс — use-case і межі транзакції.
this.orderRepository = orderRepository;
}
@Transactional(readOnly = true) // Важливо: колекції мають ініціалізуватися всередині транзакції.
public int loadItemCount(String orderNumber) {
// Читаємо «картку»: очікуємо, що items уже завантажені графом.
CustomerOrder order = orderRepository.findCardByOrderNumber(orderNumber).orElseThrow();
// Якби items не були завантажені, тут міг би бути лінивий SELECT (або навіть N+1 у циклах).
return order.getItems().size();
}
}
Якщо ви ввімкнете SQL-логи, ви маєте побачити, що виклик order.getItems().size() не запускає «пачку додаткових SELECT». Він працює на вже завантажених даних.
І ось це відчуття — «дані готові всередині use-case» — і є метою @EntityGraph. Ми не робимо зовнішній шар щасливим «випадково», ми робимо сервісний read-use-case чесним і передбачуваним.
5. Похідні запити, @Query, пагінація
Коли @EntityGraph починає подобатися, виникає природна спокуса: «А давайте тепер просто навісимо @EntityGraph на все». Це приблизно як відкрити для себе гарячі клавіші в IDE і намагатися гарячою клавішею робити каву. Можна, але буде дивно. Давайте обережно розберемо кілька типових поєднань.
@EntityGraph + похідний запит
Це «золота зона» @EntityGraph: критерій вибірки виражається іменем методу, а план завантаження — анотацією. Наприклад, список активних товарів, де для кожного товару потрібна категорія.
import org.springframework.data.jpa.repository.EntityGraph;
import java.util.List;
public interface ProductRepository extends org.springframework.data.jpa.repository.JpaRepository<Product, Long> {
// Для списку товарів підтягнемо to-one зв'язок: це зазвичай безпечніше, ніж тягнути колекції.
@EntityGraph(attributePaths = "category")
List<Product> findByStatus(ProductStatus status);
}
Для ManyToOne це зазвичай цілком безпечно. Категорія — to-one зв’язок, join не роздуває результат по рядках так, як колекції. Це хороший компроміс між простотою коду й контролем завантаження.
@EntityGraph + @Query (коли JPQL потрібен, але fetch хочеться окремо)
Іноді JPQL потрібен не заради fetch, а заради умов або порядку, який важко виразити похідним методом. У такому випадку можна зробити так: JPQL відповідає за «кого вибираємо», @EntityGraph — за «що підвантажуємо».
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface CustomerOrderRepository extends org.springframework.data.jpa.repository.JpaRepository<CustomerOrder, Long> {
// Граф відповідає за «що завантажити», а JPQL — за «кого вибрати».
@EntityGraph(attributePaths = {"items", "items.product"})
@Query("""
select o
from CustomerOrder o
where o.status = :status
""")
List<CustomerOrder> findCardsByStatus(OrderStatus status);
}
Практичний сенс у тому, що ви не змішуєте в одному місці «умови» й «план завантаження», особливо якщо один і той самий запит перевикористовується з різними потребами у fetch.
Пагінація та @EntityGraph: обережно з колекціями
Тут діє та сама фізика, що й у collection join fetch: Page по кореневій сутності погано поєднується з підвантаженням to-many колекції. @EntityGraph не обходить це обмеження, він просто задає той самий fetch-plan іншим способом.
Код нижче виглядає логічно, але з високою ймовірністю створить проблеми з вартістю читання або з коректністю результату:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
public interface CustomerOrderRepository extends org.springframework.data.jpa.repository.JpaRepository<CustomerOrder, Long> {
// Увага: колекція + Page часто призводить до «множення рядків» на SQL-рівні.
@EntityGraph(attributePaths = "items")
Page<CustomerOrder> findByStatus(OrderStatus status, Pageable pageable);
}
Для to-one зв’язків пагінація зазвичай живе спокійніше, а для колекцій — ні. Якщо вам потрібна сторінка замовлень, items майже ніколи не варто тягнути «за замовчуванням»: спочатку запитайте себе, чи потрібен тут узагалі entity graph, чи чесніше одразу читати summary/read-модель.
EntityGraphType: FETCH і LOAD
Коли ви відкриваєте документацію, ви побачите, що в @EntityGraph є параметр type. Зазвичай там два значення: FETCH і LOAD. Ця тема легко перетворюється на болото «а давайте вивчимо специфікацію JPA на 300 сторінок», але нам потрібен короткий, практичний сенс.
Головна ідея така: graph може або жорстко задати, що вантажити, а решту вважати lazy, або додати перелічене до стандартного плану завантаження з мапінгу.
| Тип графа | Інтуїтивний сенс | Як мислити в проєкті |
|---|---|---|
| FETCH | «Вантаж лише те, що я перелічив, решта — ніби lazy» | Хороший вибір для усвідомлених сценаріїв читання |
| LOAD | «Вантаж те, що я перелічив, і збережи дефолти мапінгу» | Може стати у пригоді, якщо в моделі є свідомі EAGER-дефолти |
У нашому курсі ми намагаємося тримати зв’язки LAZY за замовчуванням, тому найчастіше вам вистачить дефолта, який зазвичай FETCH, або явного вказання FETCH для читабельності.
Приклад, радше для читабельності, ніж для необхідності:
import org.springframework.data.jpa.repository.EntityGraph;
import java.util.Optional;
public interface ProductRepository extends org.springframework.data.jpa.repository.JpaRepository<Product, Long> {
// Явно фіксуємо тип графа, щоб читач бачив намір: завантажити лише перелічене.
@EntityGraph(attributePaths = "category", type = EntityGraph.EntityGraphType.FETCH)
Optional<Product> findDetailedById(Long id);
}
6. Практика @EntityGraph у shop-data-jpa
Коли команда вперше отримує в руки інструмент керування завантаженням, зазвичай є дві крайнощі. Перша — не використовувати його зовсім і потім лікувати N+1 панікою. Друга — навісити граф на все підряд і отримати надмірне завантаження, тільки тепер легалізоване. Нам потрібна доросла середина: @EntityGraph як частина контракту конкретного read-use-case.
Практично це означає, що в репозиторії корисно заводити методи з промовистими іменами під ключові сценарії. Наприклад, для товару можна мати «звичайне читання» (без графа, де зв’язки залишаються лінивими) і «картку» (із графом). Для замовлення — так само: «знайти замовлення» і «знайти картку замовлення».
Якщо дуже хочеться мати коротке правило вибору, можна тримати в голові таку мінітаблицю. Вона не замінює мислення, але допомагає не панікувати.
| Сценарій використання | Що потрібно отримати | Частий вибір |
|---|---|---|
| Картка товару | Product + category | @EntityGraph(attributePaths="category") |
| Картка замовлення | CustomerOrder + items + items.product | @EntityGraph(attributePaths={"items","items.product"}) |
| Список товарів | багато рядків, мало даних | частіше — окрема read-модель (наступна лекція) |
| Список замовлень | багато замовлень, у кожного багато items | обережно: не тягнути items «за замовчуванням» |
І ще один важливий організаційний момент: @EntityGraph — це не «лагодити лінивість», а проєктувати читання. Якщо ви відчуваєте, що граф починає розростатися, це сигнал: або use-case занадто важкий, або ви повертаєте занадто багато даних «про всяк випадок».
7. Типові помилки під час використання @EntityGraph
Помилка № 1: думати, що @EntityGraph змінює умови запиту.
Іноді розробник бачить, що метод називається findByStatus, додає @EntityGraph, і очікує, що «граф розумно підвантажить лише активні позиції» або «тепер буде інший join». Ні: умови задаються запитом — derived або @Query, а граф лише каже, що з знайденої сутності потрібно завантажити одразу. Якщо дані «не ті» — проблема у WHERE, а не в графі.
Помилка № 2: робити один величезний «універсальний граф на все».
Дуже спокусливо: «А давайте зробимо граф {"items", "items.product", "items.product.category", ...} і будемо викликати один метод». Це зазвичай закінчується тим, що ви постійно завантажуєте зайве, SQL стає ширшим, пам’ять тече швидше, а сенс use-case розмивається. Граф має бути вузьким, як хороший контракт: рівно те, що потрібно зараз.
Помилка № 3: намагатися лагодити посторінкові списки замовлень через завантаження колекцій.
@EntityGraph не скасовує фізику SQL. Якщо ви просите сторінку замовлень і одночасно просите завантажити items, ви майже напевно отримаєте важке читання. І так, це буде схоже на проблему “collection join fetch + pagination”, тільки тепер сховану в анотації. Граф найзручніше працює для карток і невеликих, обмежених читань.
Помилка № 4: використовувати @EntityGraph там, де вам узагалі не потрібна entity.
Якщо use-case — «показати таблицю з трьох колонок», то підвантажувати entity + зв’язки, а потім вручну «вирізати потрібне» — це як купити піаніно, щоб зіграти один звук «ля». Це технічно можливо, але архітектурно дивно. У таких сценаріях зазвичай логічніше одразу читати в окрему read-модель, але це тема наступної лекції.
Помилка № 5: вважати, що @EntityGraph замінює транзакційну дисципліну.
Граф допомагає завантажити потрібні зв’язки в момент читання, але він не перетворює об’єкт на «вічноживучий портал у базу». Якщо ви вийшли із сервісного методу й починаєте «дочитувати» щось, чого не було в графі, ви все одно впираєтеся в ту саму межу persistence context. Тому правильний порядок думок залишається тим самим: use-case → транзакційна межа → план завантаження → інструмент.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ