intermediate~2h

Relationships & Queries

The module where JPA quietly punishes assumptions carried over from plain SQL. Get lazy vs. eager and the N+1 problem genuinely right here, because it's the most common real-world Spring Boot performance bug.

Learning objectives

  • Beginner: Identify whether a given @OneToMany/@ManyToOne mapping defaults to lazy or eager loading.
  • Intermediate: Spot a N+1 query problem in application logs and explain why it happened.
  • Advanced: Fix an N+1 problem using JOIN FETCH or an @EntityGraph, and justify the trade-off against always eager-loading.
@Entity public class Author { @Id @GeneratedValue private Long id; private String name; @OneToMany(mappedBy = "author", cascade = CascadeType.ALL) private List<Book> books = new ArrayList<>(); } @Entity public class Book { @Id @GeneratedValue private Long id; private String title; @ManyToOne @JoinColumn(name = "author_id") private Author author; }

mappedBy on the @OneToMany side tells JPA that the Book.author field owns the actual foreign key column — without it, JPA would try to create an unnecessary join table instead of using the natural author_id column.

💻 Code example

@Entity public class Author { @Id @GeneratedValue private Long id; private String name; @OneToMany(mappedBy = "author", cascade = CascadeType.ALL) private List<Book> books = new ArrayList<>(); } @Entity public class Book { @Id @GeneratedValue private Long id; private String title; @ManyToOne @JoinColumn(name = "author_id") private Author author; }

◆ The problem

Fetching an Author doesn't necessarily mean you need their entire book list loaded too — but JPA has to decide, per relationship, whether to load related data immediately or only if/when it's actually accessed.

StrategyBehaviorDefault for
LAZYRelated data is only fetched from the database the moment it's actually accessed in code.@OneToMany, @ManyToMany
EAGERRelated data is fetched immediately, in the same query (or an immediate follow-up), whether you use it or not.@ManyToOne, @OneToOne

▲ Pitfall

Accessing a LAZY relationship's field after the originating Hibernate session/transaction has closed (e.g. from your DTO-mapping code in the controller layer, if the service method already returned) throws LazyInitializationException — a very common early-career error. Access lazy relationships inside the transactional service method that loaded the entity, before it goes out of scope, or fetch what you need explicitly (§09.3).

◆ The problem

Fetch 50 authors with findAll(), then loop through them accessing author.getBooks() for each — with LAZY loading, this triggers one query to fetch the 50 authors, plus one additional query per author to fetch their books: 51 total queries where a single well-written join could have done it in one.

N+1: one query for the parents, then one additional query per parent to lazily fetch its children — the classic Hibernate performance trap.

@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books") List<Author> findAllWithBooks(); // exactly 1 query, books loaded in the same result set

◆ Under the hood — why LAZY doesn't just prevent this automatically

LAZY loading isn't the bug — it's working exactly as designed, deferring each relationship's query until accessed. The N+1 problem is what happens when code does access that lazy relationship inside a loop over many parent entities, without the developer realizing each access is a fresh round-trip to the database. Tools like Hibernate's statistics logging (or simply logging SQL in dev) are how this gets caught — it's invisible from the Java code alone, which looks identical whether it triggers 1 query or 51.

💻 Code example

@Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books") List<Author> findAllWithBooks(); // exactly 1 query, books loaded in the same result set
public interface BookRepository extends JpaRepository<Book, Long> { Page<Book> findByAuthor(String author, Pageable pageable); } @GetMapping public Page<BookResponse> searchBooks(@RequestParam String author, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { return bookRepository.findByAuthor(author, PageRequest.of(page, size)) .map(bookMapper::toResponse); }

JpaRepository supports Pageable parameters on any query method, including derived ones from Module 08 §4 — returning a full Page with total count, current page number, and total pages, rather than requiring you to hand-compute offsets and limits.

✓ Quick recap

What does LAZY loading actually defer, exactly? The query to fetch a related entity/collection, until the moment that relationship is actually accessed in code. What makes the N+1 problem specifically dangerous to miss? The Java code looks identical whether it triggers 1 query or 51 — it's invisible without checking actual SQL logs. What's the standard fix for a known N+1 case? An explicit JOIN FETCH query pulling the parent and its needed relationship in one round trip.

💻 Code example

public interface BookRepository extends JpaRepository<Book, Long> { Page<Book> findByAuthor(String author, Pageable pageable); } @GetMapping public Page<BookResponse> searchBooks(@RequestParam String author, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { return bookRepository.findByAuthor(author, PageRequest.of(page, size)) .map(bookMapper::toResponse); }

Want a visual for this concept?

Generate a diagram tailored to “Relationships & Queries” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Exception Handling & Validation← Back to all Spring Boot chapters