Interview Prep Zoneby CuriouserLabs

Question 3 of 5 · senior level

The N+1 query problem: how do you detect it, and what's your toolbox for fixing it?

🎤 Say this first

N+1 = one query for a list, then one more query per element as lazy associations load in a loop — 1+N round trips that scale with data size, invisible in dev (N=5), lethal in prod (N=5000). Detect: SQL logging in tests, Hibernate statistics, datasource-proxy assertions in CI, APM span counts. Fix per use-case, not globally: JOIN FETCH for to-one/small to-many, @EntityGraph on repository methods, batch fetching (@BatchSize/default_batch_fetch_size) as a global mitigation turning N into N/batch, or step out of entities entirely — DTO projections for read paths.

The full picture

The bug and three fixes
// ❌ 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.

🔄 Likely follow-up questions

  • Why does pagination + collection fetch join force in-memory paging, and what's the two-query pattern?
  • How do you N+1-proof GraphQL resolvers? (DataLoader analogy)
  • When do you give up and write SQL/jOOQ for a read model?