The full picture
// ❌ 1 + N: one for orders, one per order for customer
List<Order> orders = orderRepo.findByStatus(OPEN);
orders.forEach(o -> report.add(o.getCustomer().getName())); // lazy-load each!
// Fix 1: fetch join — one query
@Query("select o from Order o join fetch o.customer where o.status = :s")
List<Order> findWithCustomer(@Param("s") Status s);
// Fix 2: entity graph — keeps derived query
@EntityGraph(attributePaths = "customer")
List<Order> findByStatus(Status s);
// Fix 3 (read path): project exactly what the screen needs — no entities at all
record OrderRow(long id, String customerName, BigDecimal total) {}
@Query("select new com.app.OrderRow(o.id, o.customer.name, o.total) …")
List<OrderRow> reportRows(Status s);- Fetch-join limits: joining multiple bag collections explodes rows/throws MultipleBagFetchException; collection fetch join + pagination degrades to in-memory paging (Hibernate warns: HHH000104) — paginate ids first, fetch in a second query.
@BatchSize(size=100)/ global batch fetching converts N selects into N/100 IN-clause selects — the pragmatic safety net for cases you didn't predict.- CI guardrail: datasource-proxy / Hibernate Statistics assertion that endpoint X executes ≤ k queries — turns regressions into red builds instead of incidents.
- Root-cause stance: default all associations to LAZY (JPA's to-one default is EAGER — override it), then opt in to fetching per use-case. Eager-by-default is unfixable later.