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);
| Type | Behaviour |
|---|---|
FETCH (default) | Listed attributes are EAGER; all others become LAZY |
LOAD | Listed 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:
- Order → Customer (JOIN)
- Order → OrderItem (JOIN)
- 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 works | SQL JOIN — loads in one query | IN clause — loads in batches |
| Collection support | Can cause in-memory pagination | Works with Pageable |
| Multiple collections | One at a time (Cartesian issue) | All in batches cleanly |
| Runtime control | Yes (inline or named) | No (annotation on entity) |
| Best for | Single associations, detail pages | Collection 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
@EntityGraphdefines 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 inlineattributePathson 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
@BatchSizefor 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.