Spring Data JPA Query Derivation and Fetch Surprises
Spring Data JPA Query Derivation and Fetch Surprises
Spring Data JPA derived queries are method names that Spring parses into JPQL at startup. findByCustomerName(String name) becomes SELECT o FROM Order o WHERE o.customerName = :name. This works well until your entity has fetch annotations that you expect the derived query to honor.
It does not.
The Lie
Spring Data JPA generates optimal queries from method names, and entity-level fetch annotations control how associations are loaded.
The Reality
Derived queries generate JPQL. JPQL ignores @Fetch(FetchMode.JOIN). Every EAGER association on the entity loads via a separate SELECT query after the main query executes. You get an N+1 that is invisible in the repository interface.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customerName;
private LocalDate orderDate;
@OneToMany(mappedBy = "order", fetch = FetchType.EAGER)
@Fetch(FetchMode.JOIN)
private Set<OrderItem> items = new HashSet<>();
}
// BAD: Derived query - the JOIN fetch mode is ignored
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomerName(String customerName);
}
// Generated SQL:
// select o1_0.id, o1_0.customer_name, o1_0.order_date from orders o1_0 where o1_0.customer_name=?
// select i1_0.order_id, i1_0.id, i1_0.product_name, i1_0.quantity, i1_0.price from order_items i1_0 where i1_0.order_id=?
// select i1_0.order_id, i1_0.id, i1_0.product_name, i1_0.quantity, i1_0.price from order_items i1_0 where i1_0.order_id=?
// ... one per matching order
The Fix
Use @EntityGraph on derived queries, or replace derived queries with explicit @Query annotations containing JOIN FETCH.
// BETTER: EntityGraph on derived query
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"items"})
List<Order> findByCustomerName(String customerName);
}
// Generated SQL:
// select o1_0.id, o1_0.customer_name, o1_0.order_date,
// i1_0.order_id, i1_0.id, i1_0.product_name, i1_0.quantity, i1_0.price
// from orders o1_0
// left join order_items i1_0 on o1_0.id = i1_0.order_id
// where o1_0.customer_name = ?
// BETTER: Explicit JPQL with JOIN FETCH
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.customerName = :name")
List<Order> findByCustomerNameWithItems(@Param("name") String name);
}
The @EntityGraph approach keeps the derived query readable. The @Query approach gives full control over the SQL. Both eliminate the N+1.
Specification Queries and Fetch Strategy
Spring Data JPA Specifications (JpaSpecificationExecutor) have the same problem. Specifications generate Criteria queries, which also ignore @Fetch(FetchMode.JOIN).
// BAD: Specification ignores fetch mode
public class OrderSpecifications {
public static Specification<Order> hasCustomer(String name) {
return (root, query, cb) -> cb.equal(root.get("customerName"), name);
}
}
// Usage:
List<Order> orders = orderRepository.findAll(OrderSpecifications.hasCustomer("Alice"));
// Same N+1 as derived queries
// BETTER: Add fetch join inside the Specification
public class OrderSpecifications {
public static Specification<Order> hasCustomerWithItems(String name) {
return (root, query, cb) -> {
// Only add fetch for non-count queries
if (query.getResultType() != Long.class && query.getResultType() != long.class) {
root.fetch("items", JoinType.LEFT);
}
return cb.equal(root.get("customerName"), name);
};
}
}
The query.getResultType() check is necessary because Spring Data JPA reuses the same Specification for both the data query and the count query (when using Page return types). Adding a fetch join to a count query produces an error. This defensive check is ugly but required.
The Cost Model
For a findByCustomerName returning 50 results, each with 8 child items:
- Without EntityGraph: 51 queries, 50 * 1ms round trips = 50ms network overhead
- With EntityGraph: 1 query, 0 additional round trips
The query generation happens at application startup. Spring Data JPA parses every derived query method and validates it against the entity metamodel. Adding @EntityGraph does not change startup time. It changes the generated JPQL to include a LEFT JOIN, which is resolved at query execution time.
The lesson: derived queries are convenient for simple lookups on non-associated columns. The moment your entity has EAGER associations or you need associated data, switch to @Query with explicit fetch control or annotate with @EntityGraph.