Entity Graphs and Batch Loading: Precision Fetching

The Problem @EntityGraph Solves

JOIN FETCH in @Query solves N+1 but creates inflexibility: the fetch plan is baked into the query. Two different use cases — an order detail page (needs items + customer) and an order list page (needs only customer) — require two different queries with different JOIN FETCHes.

@EntityGraph separates the fetch plan from the query. You define what to load at the call site, and Spring Data JPA generates the appropriate JOIN. The same repository method can serve different fetch plans.


Named Entity Graphs

Define a fetch plan on the entity with @NamedEntityGraph:

@Entity
@Table(name = "orders")
@NamedEntityGraph(
    name = "Order.withCustomer",
    attributeNodes = @NamedAttributeNode("customer")
)
@NamedEntityGraph(
    name = "Order.withCustomerAndItems",
    attributeNodes = {
        @NamedAttributeNode("customer"),
        @NamedAttributeNode("items")
    }
)
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();

    private OrderStatus status;
    private BigDecimal total;
}

Reference the graph in a repository method:

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph("Order.withCustomer")
    List<Order> findByStatus(OrderStatus status);

    @EntityGraph("Order.withCustomerAndItems")
    Optional<Order> findById(Long id); // override the default findById
}

The findByStatus call generates a JOIN to load customer but leaves items lazy. The findById call generates JOINs for both customer and items.


Inline @EntityGraph (without @NamedEntityGraph)

Define the fetch plan directly on the repository method with attributePaths:

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Load customer only
    @EntityGraph(attributePaths = {"customer"})
    List<Order> findByStatus(OrderStatus status);

    // Load customer and items
    @EntityGraph(attributePaths = {"customer", "items"})
    Optional<Order> findWithFullDetails(Long id);

    // Load nested association: items and each item's product
    @EntityGraph(attributePaths = {"customer", "items", "items.product"})
    Optional<Order> findWithItemsAndProducts(Long id);
}

attributePaths eliminates the need for @NamedEntityGraph on the entity — less ceremony for simple cases.


FETCH vs LOAD Graph Type

@EntityGraph has a type parameter:

@EntityGraph(attributePaths = {"customer"}, type = EntityGraph.EntityGraphType.FETCH)
List<Order> findByStatus(OrderStatus status);
TypeBehaviour
FETCH (default)Listed attributes are EAGER; all others become LAZY
LOADListed attributes are EAGER; others retain their mapping default

FETCH is usually what you want — it explicitly controls what gets loaded and leaves everything else lazy.


@EntityGraph with @Query

Entity graphs compose with @Query:

@EntityGraph(attributePaths = {"customer", "items"})
@Query("SELECT o FROM Order o WHERE o.total > :minTotal AND o.status = :status")
List<Order> findLargeOrders(
    @Param("minTotal") BigDecimal minTotal,
    @Param("status") OrderStatus status
);

The custom JPQL filters results; the entity graph defines what is fetched. This replaces the need for JOIN FETCH in the query string — the graph handles it.


Programmatic Entity Graphs via EntityManager

When you need dynamic fetch plans that can’t be expressed statically:

@Service
@Transactional(readOnly = true)
public class OrderService {

    @Autowired
    private EntityManager entityManager;

    public Order findWithDynamicGraph(Long orderId, boolean includeItems, boolean includeReviews) {
        EntityGraph<Order> graph = entityManager.createEntityGraph(Order.class);
        graph.addAttributeNodes("customer");

        if (includeItems) {
            Subgraph<OrderItem> itemGraph = graph.addSubgraph("items");
            itemGraph.addAttributeNodes("product");
        }

        if (includeReviews) {
            graph.addAttributeNodes("reviews");
        }

        Map<String, Object> hints = Map.of(
            "jakarta.persistence.fetchgraph", graph
        );

        return entityManager.find(Order.class, orderId, hints);
    }
}

The programmatic API builds the graph at runtime — useful for conditional fetching based on request context.


Subgraphs: Fetching Nested Associations

A @NamedSubgraph fetches associations of associations:

@NamedEntityGraph(
    name = "Order.full",
    attributeNodes = {
        @NamedAttributeNode("customer"),
        @NamedAttributeNode(value = "items", subgraph = "items.product")
    },
    subgraphs = @NamedSubgraph(
        name = "items.product",
        attributeNodes = @NamedAttributeNode("product")
    )
)
@Entity
public class Order { ... }

This fetches:

  1. Order → Customer (JOIN)
  2. Order → OrderItem (JOIN)
  3. OrderItem → Product (JOIN)

All in one query, without N+1 at any level.

@EntityGraph("Order.full")
Optional<Order> findById(Long id);

The same with inline syntax:

@EntityGraph(attributePaths = {"customer", "items.product"})
Optional<Order> findById(Long id);

"items.product" traverses the path: order → items → product.


Combining @EntityGraph with Pagination

Entity graphs and pagination don’t always mix cleanly. When you use an entity graph that fetches a collection (items), Hibernate issues a HHH90003004 warning:

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

This means Hibernate fetches ALL rows (ignoring the LIMIT) and applies pagination in memory — potentially loading millions of rows.

Fix: use a two-query approach for paginated collection fetching:

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Step 1: get IDs with pagination (no collection fetch)
    @Query("SELECT o.id FROM Order o WHERE o.status = :status")
    Page<Long> findIdsByStatus(@Param("status") OrderStatus status, Pageable pageable);

    // Step 2: fetch full orders for those IDs (with collection)
    @EntityGraph(attributePaths = {"customer", "items"})
    @Query("SELECT o FROM Order o WHERE o.id IN :ids")
    List<Order> findByIdIn(@Param("ids") List<Long> ids);
}
@Service
@Transactional(readOnly = true)
public class OrderService {

    public Page<Order> findWithDetails(OrderStatus status, Pageable pageable) {
        Page<Long> ids = orderRepository.findIdsByStatus(status, pageable);
        List<Order> orders = orderRepository.findByIdIn(ids.getContent());
        return new PageImpl<>(orders, pageable, ids.getTotalElements());
    }
}

Two queries: one COUNT + pagination query, one entity-graph query. Correct pagination, no in-memory blowup.


Batch Loading vs Entity Graphs

@EntityGraph@BatchSize
How it worksSQL JOIN — loads in one queryIN clause — loads in batches
Collection supportCan cause in-memory paginationWorks with Pageable
Multiple collectionsOne at a time (Cartesian issue)All in batches cleanly
Runtime controlYes (inline or named)No (annotation on entity)
Best forSingle associations, detail pagesCollection associations, lists

Combine both: use an entity graph for single associations (ManyToOne, OneToOne) and @BatchSize for collections:

// Entity
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;    // loaded via EntityGraph (JOIN)

@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 25)
private List<OrderItem> items; // loaded via BatchSize (IN query)
// Repository
@EntityGraph(attributePaths = {"customer"}) // joins customer only
List<Order> findByStatus(OrderStatus status);

When items are accessed, @BatchSize fires IN queries for 25 IDs at a time. Total for 100 orders: 1 (orders+customers) + 4 (items in batches of 25) = 5 queries.


Complete Fetching Strategy Reference

@Service
@Transactional(readOnly = true)
public class OrderService {

    private final OrderRepository orderRepository;

    // Order list — just need customer name
    public List<OrderDto> getOrderList(OrderStatus status) {
        return orderRepository.findByStatus(status) // @EntityGraph(customer)
            .stream()
            .map(o -> new OrderDto(o.getId(), o.getCustomer().getName(), o.getTotal()))
            .toList();
        // SQL: 1 query (orders JOIN customers)
    }

    // Order detail — need customer + all items with products
    public OrderDetailDto getOrderDetail(Long id) {
        Order order = orderRepository.findWithFullDetails(id) // @EntityGraph(customer,items.product)
            .orElseThrow(() -> new EntityNotFoundException("Order not found"));
        return OrderDetailDto.from(order);
        // SQL: 1 query (orders JOIN customers JOIN order_items JOIN products)
    }

    // Paginated order list — safe with collection fetch
    public Page<OrderDto> getOrderPage(OrderStatus status, Pageable pageable) {
        Page<Long> ids = orderRepository.findIdsByStatus(status, pageable); // 2 queries (data + count)
        if (ids.isEmpty()) return ids.map(id -> null); // or return empty page
        List<Order> orders = orderRepository.findByIdIn(ids.getContent()); // 1 query with graph
        return new PageImpl<>(
            orders.stream().map(OrderDto::from).toList(),
            pageable,
            ids.getTotalElements()
        );
        // SQL: 3 queries total, correct pagination
    }
}

Summary

  • @EntityGraph defines a fetch plan separately from the query — the same repository method can serve different fetch needs.
  • Use named entity graphs (@NamedEntityGraph) on the entity class, or inline attributePaths on the repository method.
  • Subgraphs ("items.product" path syntax) fetch nested associations in one JOIN.
  • EntityGraph.EntityGraphType.FETCH (default): listed attributes EAGER, rest LAZY.
  • Combining entity graphs with paginated collection fetches triggers in-memory pagination — fix with the two-query approach (fetch IDs with pagination, then fetch entities for those IDs).
  • Use entity graphs for single associations and @BatchSize for collections — they complement each other.

Next: Article 28 covers auditing — automatically tracking who created and modified entities using @CreatedDate, @LastModifiedBy, and Hibernate Envers for full audit history.