StackWise
Spring Boot 4Data Access

Relationships, Fetching & the N+1 Problem

Mapping @OneToMany and @ManyToOne correctly, LAZY vs EAGER, the N+1 query problem and how to fix it with fetch joins, entity graphs and batching.

intermediate 4 min readLesson 12 of 23

Mapping associations

erDiagram
  CUSTOMER ||--o{ ORDERS : places
  ORDERS ||--|{ ORDER_LINE : contains
  PRODUCT ||--o{ ORDER_LINE : "appears in"
  ORDERS {
    bigint id PK
    bigint customer_id FK
    varchar status
  }
  ORDER_LINE {
    bigint id PK
    bigint order_id FK
    bigint product_id FK
    int quantity
  }
@Entity
public class OrderLine {
    @Id @GeneratedValue private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)   // owning side: has the FK column
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Product product;

    private int quantity;
}

@Entity
public class Order {
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)   // inverse side
    private List<OrderLine> lines = new ArrayList<>();
}

Key rules:

  • The side with the foreign key (@ManyToOne, @JoinColumn) is the owning side; mappedBy marks the inverse side. Only changes to the owning side are written to the database.
  • Provide helper methods (order.addLine(...)) that update both sides.
  • Use cascade and orphanRemoval only from a parent to children it truly owns (aggregate root → its lines), never towards shared entities like Product or Customer.
  • Avoid @ManyToMany with extra data — model the join table as its own entity.

Fetch types

Association JPA default Recommended
@ManyToOne, @OneToOne EAGER Set fetch = LAZY explicitly
@OneToMany, @ManyToMany LAZY Keep LAZY

EAGER is a trap: it loads the association every time the entity is loaded — even when you don’t need it — and often via extra queries. Make everything LAZY and decide per use case what to fetch.

The N+1 problem

Load N orders, then touch each order’s lazy association: Hibernate runs 1 query for the orders plus N more — one per order.

List<Order> orders = orderRepository.findByStatus(PAID);     // 1 query
for (Order o : orders) {
    total += o.getLines().size();                             // +1 query PER order
}

Simulate it — and the fix — with a fake database that counts queries:

NPlusOne.javaJava 21
import java.util.*;
import java.util.stream.*;

record Line(long orderId, String sku) {}
record Order(long id, String customer) {}

class FakeDb {
    int queries = 0;
    final List<Order> orders = LongStream.rangeClosed(1, 50).mapToObj(i -> new Order(i, "c" + i % 7)).toList();
    final List<Line> lines = LongStream.rangeClosed(1, 50).boxed()
        .flatMap(id -> Stream.of(new Line(id, "A"), new Line(id, "B"))).toList();

    List<Order> selectOrders() { queries++; return orders; }
    List<Line> selectLinesFor(long orderId) {                       // "lazy loading" one order at a time
        queries++; return lines.stream().filter(l -> l.orderId() == orderId).toList();
    }
    List<Line> selectLinesFor(Collection<Long> orderIds) {          // batch / IN clause
        queries++; return lines.stream().filter(l -> orderIds.contains(l.orderId())).toList();
    }
    Map<Order, List<Line>> selectOrdersJoinLines() {                // fetch join
        queries++;
        Map<Long, List<Line>> byOrder = lines.stream().collect(Collectors.groupingBy(Line::orderId));
        Map<Order, List<Line>> result = new LinkedHashMap<>();
        orders.forEach(o -> result.put(o, byOrder.getOrDefault(o.id(), List.of())));
        return result;
    }
}

class Main {
    public static void main(String[] args) {
        var db = new FakeDb();
        int count = 0;
        for (Order o : db.selectOrders()) count += db.selectLinesFor(o.id()).size();
        System.out.println("N+1 lazy loading:  " + count + " lines, " + db.queries + " queries");

        db.queries = 0;
        List<Order> orders = db.selectOrders();
        List<Line> all = new ArrayList<>();
        List<Long> ids = orders.stream().map(Order::id).toList();
        for (int i = 0; i < ids.size(); i += 25) all.addAll(db.selectLinesFor(ids.subList(i, Math.min(i + 25, ids.size()))));
        System.out.println("batch size 25:     " + all.size() + " lines, " + db.queries + " queries");

        db.queries = 0;
        int joined = db.selectOrdersJoinLines().values().stream().mapToInt(List::size).sum();
        System.out.println("join fetch:        " + joined + " lines, " + db.queries + " query");
    }
}

With 50 orders that’s 51 queries instead of 1. In production with network latency, this turns a 10 ms endpoint into a 2-second one.

Fixing N+1

1. Fetch join (JPQL)

@Query("select distinct o from Order o join fetch o.lines l join fetch l.product where o.status = :status")
List<Order> findWithLines(OrderStatus status);

2. Entity graph (declarative)

@EntityGraph(attributePaths = {"lines", "lines.product"})
List<Order> findByStatus(OrderStatus status);

3. Batch fetching

spring.jpa.properties.hibernate.default_batch_fetch_size=50

Hibernate then loads lazy collections for up to 50 parents in one IN (...) query — N+1 becomes N/50+1 without changing any query.

4. DTO projections

For read-only views, select exactly the columns you need into records — no entities, no lazy loading at all.

Detecting N+1

  • Log SQL in development: logging.level.org.hibernate.SQL=debug (and org.hibernate.orm.jdbc.bind=trace for parameters)
  • spring.jpa.properties.hibernate.generate_statistics=true reports query counts per session
  • Assert query counts in tests (e.g. with the datasource-proxy library)
  • Tracing (Micrometer + OpenTelemetry) shows dozens of identical spans under one request

LazyInitializationException

Accessing a lazy association after the persistence context is closed (outside a transaction, with open-in-view disabled) throws LazyInitializationException. The fix is not EAGER or open-in-view — it’s fetching what the use case needs inside the transactional service method (fetch join, entity graph or DTO projection).

Finished this lesson?

Mark it complete to track your progress through Spring Boot 4.