JavaRush /Курсы /Hibernate deep-dive /EntityGraph как fetc...

EntityGraph как fetch-plan для чтения

Hibernate deep-dive
8 уровень , 2 лекция
Открыта

1. Иногда вместо JOIN FETCH нужен EntityGraph

Если JOIN FETCH — это «вкрутить турбину прямо в запрос», то EntityGraph — это «сказать Hibernate, какие детали мы хотим получить вместе с машиной, не переписывая договор купли‑продажи». В реальном проекте запрос (фильтрация, сортировка) часто остаётся тем же самым, а вот набор нужных связей меняется от сценария к сценарию: для списка — одно, для детальной карточки — другое, для внутренней бизнес-операции — третье.

Представим понятный кейс из нашего Commerce Persistence Lab. Мы читаем PurchaseOrder по orderNumber. Иногда нам нужен только сам заказ и клиент (чтобы показать шапку). Иногда нужен заказ плюс позиции (чтобы отрисовать состав). Иногда нужен заказ плюс позиции плюс product внутри каждой позиции (чтобы показать SKU и название). Если каждый раз переписывать JPQL под новую вариацию, вы быстро получите зоопарк методов вида findOrderDetailedWithCustomerAndItemsAndProductsAndWhateverElse(), и через неделю сами перестанете понимать, чем они отличаются.

EntityGraph решает именно эту боль: он позволяет оставить запрос (условия) прежним, но «прикрутить» к нему fetch-plan — список атрибутов/связей, которые надо загрузить заранее в рамках текущего чтения.

2. Что такое EntityGraph

Идея EntityGraph очень простая, если объяснять по-человечески: это «карта того, какие связи нужно инициализировать при загрузке entity». То есть это не новый язык запросов, не замена where/order by и не способ выбрать “какие строки” — он выбирает “какие части объекта” надо материализовать сразу. Он живёт в мире fetch-стратегий, а не в мире фильтрации.

Полезно держать в голове вот такую упрощённую схему:

flowchart LR
    A["JPQL/Repository method
что читаем (условия)"] --> B["EntityGraph
что подгружаем сразу (fetch-plan)"] B --> C["Hibernate fetch plan"] C --> D["SQL (join / secondary selects)
и результат в persistence context"]

То есть у нас появляется две «ручки управления» вместо одной:

- запрос отвечает на вопрос «какие заказы/товары/клиенты нужны?»;
- граф отвечает на вопрос «что именно подгрузить вместе с ними сейчас?».

Это очень хорошо ложится на философию дня: LAZY остаётся базовой моделью (мы не превращаем entity в «всё EAGER навсегда»), а eagerness делаем сценарно, под конкретное чтение.

Небольшая мини‑таблица для закрепления различий (в рамках нашего сегодняшнего дня):

Инструмент Где задаётся Что изменяет Ключевая мысль
JOIN FETCH в JPQL/HQL форму запроса (часто join в SQL) и инициализацию связи «Явно переписали запрос под detail-read»
EntityGraph отдельно (аннотацией или в коде) fetch-plan: какие связи надо загрузить «Запрос тот же, меняем только план загрузки»

Здесь важно не переоценить граф. EntityGraph меняет способ описания fetch-plan, а не физику чтения. Если граф включает to-many, Hibernate всё равно платит цену collection loading: это могут быть join’ы, secondary SELECT, чувствительность к pagination и все те же вопросы к нескольким коллекциям. Граф не отменяет эту математику, он просто позволяет управлять fetch-plan отдельно от фильтрации.

3. Варианты объявления EntityGraph

@NamedEntityGraph в entity

Самый «классический» способ использовать EntityGraph — описать именованный граф прямо на сущности. Это похоже на то, как вы даёте имени своему любимому рецепту: “борщ_как_у_бабушки”. Потом вы можете этот рецепт «вызывать по имени» в разных местах. Главный плюс — граф централизован и переиспользуем, главный минус — вы добавляете в entity ещё одну “служебную” деталь.

Сделаем в проекте простой граф для чтения детального заказа: заказ + клиент + позиции. Важно: мы не тащим сюда ничего лишнего, потому что граф должен отражать конкретный сценарий (например, экран «детали заказа» в backoffice).

Пример (фрагмент PurchaseOrder):

import jakarta.persistence.Entity;
import jakarta.persistence.NamedAttributeNode;
import jakarta.persistence.NamedEntityGraph;

// Именованный граф: фиксируем сценарный fetch-plan, чтобы переиспользовать по имени
@NamedEntityGraph(
    name = "PurchaseOrder.withCustomerAndItems",
    attributeNodes = {
        // Подгружаем клиента вместе с заказом
        @NamedAttributeNode("customer"),
        // Подгружаем позиции заказа (коллекцию)
        @NamedAttributeNode("items")
    }
)
@Entity // Сущность остаётся LAZY-ориентированной, граф задаёт eager только для чтения
public class PurchaseOrder {
    // ...
}

Обратите внимание на две вещи. Во‑первых, мы называем граф так, чтобы из названия было видно назначение: withCustomerAndItems. Во‑вторых, мы перечисляем атрибуты entity, а не таблицы и не колонки. customer и items — это поля в PurchaseOrder, и именно их Hibernate должен постараться загрузить сразу.

Теперь давайте применим этот граф при чтении через EntityManager.find(...). Это важно: EntityGraph умеет работать даже там, где вы не пишете JPQL вообще.

import jakarta.persistence.EntityGraph;
import jakarta.persistence.EntityManager;
import java.util.Map;

// Достаём граф по имени (как “рецепт” fetch-plan)
EntityGraph
   graph = entityManager.getEntityGraph("PurchaseOrder.withCustomerAndItems");

PurchaseOrder order = entityManager.find(
    PurchaseOrder.class,
    orderId,
    // Подсказываем провайдеру: применить fetchgraph при конкретном чтении
    Map.of("jakarta.persistence.fetchgraph", graph)
);

Код выглядит почти «как обычно», только мы добавили properties map с ключом "jakarta.persistence.fetchgraph". Это стандартный JPA-механизм: мы говорим провайдеру (Hibernate), что для этого чтения нужно применить fetch-plan.

И да, ключ начинается с jakarta.persistence, потому что мы в мире Spring Boot 4 + Hibernate 7, где уже Jakarta Persistence, а не javax.persistence.

Динамический граф в коде

Иногда вам не хочется добавлять аннотации в entity. Причины бывают разные: вы считаете доменную модель «священной коровой» (и она обижается на аннотации), у вас есть legacy‑ограничения, или вы просто хотите быстро собрать граф для лаборатории и потом выкинуть. Для таких случаев JPA позволяет создать EntityGraph программно.

Пример: соберём граф для PurchaseOrder прямо в коде сервиса/лаборатории и применим его к JPQL-запросу через hint.

import jakarta.persistence.EntityGraph;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;

// Создаём динамический граф для конкретной сущности
EntityGraph<PurchaseOrder> graph = entityManager.createEntityGraph(PurchaseOrder.class);
// Явно перечисляем, какие атрибуты нужно подгрузить
graph.addAttributeNodes("customer", "items");

TypedQuery<PurchaseOrder> q = entityManager.createQuery(
    // JPQL отвечает только за “какую строку” (какой заказ), без fetch join
    "select o from PurchaseOrder o where o.id = :id",
    PurchaseOrder.class
);
q.setParameter("id", orderId);
// Навешиваем fetch-plan через hint (режим loadgraph или fetchgraph — по задаче)
q.setHint("jakarta.persistence.loadgraph", graph);

PurchaseOrder order = q.getSingleResult();

Здесь мы сделали две важные вещи. Мы оставили JPQL максимально простым (без fetch join), то есть он отвечает только за “какой заказ взять”. А затем через setHint() навесили на него граф, то есть ответили на вопрос “что подгрузить”.

В реальном проекте такой стиль очень удобен, когда фильтрация сложная и многократно переиспользуется, а вот fetch-plan плавающий. Вы не хотите копировать один и тот же where в три варианта запроса только из-за того, что в одном месте нужно items, а в другом — нет.

4. Режимы fetchgraph и loadgraph

На EntityGraph есть одна тонкость, которую полезно понять сейчас, иначе вы будете страдать ровно в тот момент, когда времени на страдание нет (то есть в проде). У JPA есть два близких, но разных режима применения графа: fetchgraph и loadgraph. Они различаются тем, что происходит с атрибутами, которые не перечислены в графе.

Давайте проговорим человеческим языком. Оба режима говорят: «то, что перечислено в графе, подгрузи сейчас». Разница начинается дальше.

fetchgraph звучит как «грузим только то, что я указал». А loadgraph — как «грузим то, что я указал, а остальное — как настроено в маппинге». Это не буквальная цитата спецификации, но рабочая инженерная интуиция.

Сведём в компактную таблицу:

Режим Что делает с атрибутами из графа Что делает с атрибутами вне графа
fetchgraph (jakarta.persistence.fetchgraph) считает их “надо загрузить” считает их “не грузить сейчас” (даже если где-то EAGER)
loadgraph (jakarta.persistence.loadgraph) считает их “надо загрузить” оставляет поведение «как в маппинге» (LAZY/EAGER как задано)

Теперь важная практическая мысль. В нашем курсе мы постоянно повторяем: не делайте EAGER по умолчанию. Это означает, что если ваша модель дисциплинированно LAZY-ориентирована, то разница между fetchgraph и loadgraph будет проявляться реже. Но в реальной жизни вы можете встретить EAGER (или “случайно eager” через какие-нибудь настройки/наследование/устаревший код). И тогда fetchgraph становится способом «обрезать» лишние eager‑подгрузки и сделать чтение более предсказуемым.

Если вы работаете через Spring Data, то вы тоже встретите эти понятия, только в другом виде: там @EntityGraph имеет параметр type, который принимает FETCH или LOAD.

5. EntityGraph в Spring Data JPA

До сих пор мы говорили на языке JPA (EntityManager). Это полезно для понимания, но в реальном приложении на Spring Boot вы почти всегда живёте через репозитории. Хорошая новость: Spring Data JPA умеет применять EntityGraph без прямой возни с EntityManager, и это часто делает код сильно чище.

Сделаем в нашем проекте небольшой шаг: добавим в PurchaseOrderRepository метод, который читает заказ вместе с клиентом и позициями.

import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {

    // Сценарный fetch-plan: для этого метода подгружаем нужные связи заранее
    @EntityGraph(attributePaths = {"customer", "items"})
    Optional<PurchaseOrder> findOneByOrderNumber(String orderNumber);
}

Здесь происходит маленькая магия (но теперь уже “понятная магия”). Spring Data возьмёт ваш метод (который сам по себе выглядит как обычный derived query), построит запрос, а затем добавит к нему JPA-hint с графом, собранным из attributePaths.

Если у вас есть named graph в entity, вы можете сослаться и на него:

import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {

    // Используем именованный граф из сущности (переиспользуем “рецепт”)
    @EntityGraph(value = "PurchaseOrder.withCustomerAndItems")
    Optional<PurchaseOrder> findById(Long id);
}

Обратите внимание на трюк: мы переопределили findById() в своём интерфейсе, добавив аннотацию. Сигнатура та же, но поведение чтения теперь “детализированное”. Это удобно, но применять нужно аккуратно: если вы везде начнёте использовать “детальный” findById, то можете легко снова приехать в overfetching (только уже через граф, а не через EAGER).

Чтобы не склеивать все use case в одно, чаще делают два метода: “обычный” и “детальный”. Например, обычный findById оставить как есть, а для деталек завести отдельный метод с @Query:

import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {

    // JPQL остаётся простым, а fetch-plan выносим в EntityGraph
    @EntityGraph(attributePaths = {"customer", "items"})
    @Query("select o from PurchaseOrder o where o.id = :id")
    Optional<PurchaseOrder> findDetailedById(Long id);
}

Заметьте: запрос остался простым, без join fetch. Мы вынесли fetch-plan в @EntityGraph. Это как раз тот стиль, ради которого мы сегодня всё и затеяли.

И ещё один приятный бонус Spring Data: attributePaths поддерживает вложенные пути. Если вам нужно загрузить items.product (например, чтобы показать SKU в позициях заказа), вы можете указать путь строкой:

// Вложенный путь: подгружаем product внутри каждой позиции заказа
@EntityGraph(attributePaths = {"customer", "items", "items.product"})
Optional<PurchaseOrder> findDetailedById(Long id);

Да, это выглядит как “строка‑магия”. Но это управляемая строка: она отражает навигацию по полям entity-графа.

6. Сквозной пример: детали заказа через EntityGraph

Чтобы всё это не выглядело как «аннотации ради аннотаций», давайте свяжем с сервисным use case. Пусть у нас есть OrderQueryService, который возвращает детальную модель заказа для backoffice-экрана (пока без DTO и без проекций — мы сегодня этим сознательно не занимаемся).

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderQueryService {

    private final PurchaseOrderRepository orderRepository;

    public OrderQueryService(PurchaseOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    // Чтение держим в транзакции, чтобы LAZY-части не превратились в LazyInitializationException
    @Transactional(readOnly = true)
    public PurchaseOrder loadOrderDetails(Long orderId) {
        return orderRepository.findDetailedById(orderId)
            // В примере без кастомного сообщения: важно, что ошибка проявится сразу
            .orElseThrow();
    }
}

Здесь важна связка с тем, что мы проходили раньше. Мы всё ещё держим чтение внутри транзакции (@Transactional(readOnly = true)), потому что иначе любая LAZY-связь может привести вас к LazyInitializationException. EntityGraph не отменяет эту реальность, он лишь помогает загрузить нужные связи заранее, чтобы потом вы спокойно работали с объектом внутри unit of work.

7. Проверяем EntityGraph по SQL trace

С EntityGraph есть одна ловушка для новичка: вы повесили аннотацию, код «не упал», и кажется, что всё отлично. Но Hibernate — система хитрая. Он может выбрать join, может выбрать дополнительные SELECT, может частично оптимизировать, может сгенерировать distinct, может изменить порядок запросов. Если вы не смотрите SQL trace, вы как будто чините электрику в квартире по звуку: иногда получится, но метод сомнительный.

Поэтому проверка всегда одна и та же, как мантра курса. Вы запускаете сценарий под профилем sql-trace (и при желании stats), выполняете чтение и смотрите, что произошло: сколько запросов, какого они вида, где исчезли вторичные SELECT, а где, наоборот, появились join’ы.

Чтобы почувствовать разницу, полезно сравнить два чтения “на одном и том же запросе”:

1) Без графа: читаем заказ, затем трогаем getItems() и getCustomer() — смотрим вторичные SELECT.

2) С графом: читаем заказ тем же запросом, но через @EntityGraph — смотрим, ушли ли вторичные SELECT и какой стала форма SQL.

На уровне кода это может выглядеть настолько скучно, что даже красиво:

// Важно: этот код имеет смысл проверять внутри транзакции (или в рамках теста с открытой сессией)
PurchaseOrder order = orderQueryService.loadOrderDetails(orderId);

// Точка контроля: обращаемся к связям и смотрим в логах, появились ли вторичные SELECT
order.getCustomer().getEmail();     // в идеале без отдельного SELECT
order.getItems().size();            // в идеале без отдельного SELECT

И вот тут вы открываете лог и видите реальность. Иногда это будет один SQL с join’ами. Иногда два SQL: один на заказ и клиента, второй на коллекцию. И это нормально: EntityGraph — это не обещание «один запрос на всё», это обещание «постараюсь загрузить это заранее как часть одного чтения».

И отсюда видно ещё одно направление выбора: иногда лучше не пытаться загрузить всё заранее, а оставить LAZY и просто сократить число вторичных запросов. Это уже другой тип решения — не eager-описание графа, а grouped lazy loading.

8. Типичные ошибки при работе с EntityGraph

Ошибка №1: думать, что EntityGraph — это способ фильтрации данных.
Иногда студенты пытаются использовать граф как будто это where: мол, «если я добавлю items в граф, то будут загружены только заказы с items». Нет. EntityGraph не выбирает строки. Он выбирает, какие связи грузить. Фильтрация остаётся задачей запроса (where) и вашей логики.

Ошибка №2: путать fetchgraph и loadgraph, а потом удивляться “лишним” загрузкам.
Снаружи разница выглядит микроскопической: поменяли одно слово в hint’е или параметр type в @EntityGraph. Но последствия могут быть вполне заметными, особенно если где-то в маппинге живёт EAGER. Когда вы не понимаете, что случилось с неуказанными атрибутами, вы теряете предсказуемость чтения — а предсказуемость в ORM дороже золота.

Ошибка №3: сделать один гигантский граф “на всё” и радоваться, что запросов стало меньше.
Да, запросов может стать меньше, но размер result set и ширина данных могут стать больше. Потом вы откроете SQL и увидите, что вы случайно превратили чтение в монстра с кучей join’ов, который делает базе больно. Граф должен быть сценарным: под конкретный экран/операцию, а не «универсальным графом каталога всего мира».

Ошибка №4: использовать EntityGraph как “лекарство” от LazyInitializationException без исправления границы транзакции.
Граф помогает подгрузить связи в момент чтения, но если вы вытащили entity за пределы транзакции и там продолжаете лазить по LAZY-связям, вы всё равно можете наступить на исключение. Архитектурная дисциплина остаётся прежней: либо грузим всё нужное внутри транзакции, либо проектируем чтение так, чтобы вне транзакции вы не трогали ленивые части.

Ошибка №5: не проверять результат по SQL trace и считать, что аннотация гарантирует конкретный SQL.
EntityGraph влияет на fetch-plan, а Hibernate уже сам решает, как именно его реализовать. Если вы не смотрите SQL, вы не видите цену решения. В deep-dive курсе это почти преступление (но не переживайте, мы просто заставим вас включить sql-trace, и всё пройдёт).

1
Задача
Hibernate deep-dive, 8 уровень, 2 лекция
Недоступна
Именованный `EntityGraph` для заказа
Именованный `EntityGraph` для заказа
1
Задача
Hibernate deep-dive, 8 уровень, 2 лекция
Недоступна
Динамический `EntityGraph` в коде
Динамический `EntityGraph` в коде
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ