PrepZone Logo
PrepZone

One-to-Many, Lazy vs Eager

Entity relationships, fetch types, and the N+1 query trap.

Why this matters

  • BookStore has authors, books, and orders — relationships are unavoidable in any real domain model.
  • Eager fetching loads everything upfront; lazy fetching defers until accessed — each has trade-offs.
  • The N+1 problem is the most common JPA performance bug in production APIs.
Author1
BookN
Order1
Author has many Books. Order has many OrderItems referencing Books.

One-to-many: Author and Books

Java
@Entity
@Table(name = "authors")
public class Author {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Book> books = new ArrayList<>();

    public void addBook(Book book) {
        books.add(book);
        book.setAuthor(this);
    }
}

@Entity
@Table(name = "books")
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id", nullable = false)
    private Author author;
}

mappedBy marks the inverse side. cascade = ALL propagates persist/remove to children. orphanRemoval deletes books removed from the collection.

FetchType: LAZY vs EAGER

Java
@ManyToOne(fetch = FetchType.LAZY)   // default for @ManyToOne in Boot 3
private Author author;

@OneToMany(fetch = FetchType.LAZY)   // default for @OneToMany
private List<Book> books;
Fetch typeBehaviourRisk
LAZYLoads on first accessLazyInitializationException outside a transaction
EAGERLoads with parentN+1 queries, over-fetching
  • LAZY

    BehaviourLoads on first access
    RiskLazyInitializationException outside a transaction
  • EAGER

    BehaviourLoads with parent
    RiskN+1 queries, over-fetching

Default to LAZY. Use JOIN FETCH or DTO projections when you need related data.

Relationship types in BookStore

  • @ManyToOne — Many books belong to one author. Foreign key on the "many" side.
  • @OneToMany — Inverse of @ManyToOne. Use mappedBy, not @JoinColumn.
  • @ManyToMany — Books and tags. Requires a join table.
  • @OneToOne — Book and its inventory record. Rare; often better as @ManyToOne.

The N+1 problem

Listing 20 books with eager author loading:

Java
SELECT * FROM books;                    -- 1 query
SELECT * FROM authors WHERE id = 1;     -- +1 per book
SELECT * FROM authors WHERE id = 2;     -- ...
-- 21 queries total for 20 books!

Fix with JOIN FETCH:

Java
@Query("SELECT b FROM Book b JOIN FETCH b.author")
List<Book> findAllWithAuthor();

Or use @EntityGraph:

Java
@EntityGraph(attributePaths = {"author"})
List<Book> findAll();

Many-to-many: Books and Tags

Java
@Entity
@Table(name = "tags")
public class Tag {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String name;

    @ManyToMany(mappedBy = "tags")
    private Set<Book> books = new HashSet<>();
}

@Entity
public class Book {
    @ManyToMany
    @JoinTable(
        name = "book_tags",
        joinColumns = @JoinColumn(name = "book_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id")
    )
    private Set<Tag> tags = new HashSet<>();
}

Hibernate creates the book_tags join table automatically.

DTO projection to avoid over-fetching

Instead of loading full entity graphs, query only what the API needs:

Java
public record BookWithAuthorResponse(
    Long id, String title, String authorName
) {
    public BookWithAuthorResponse(Book book) {
        this(book.getId(), book.getTitle(), book.getAuthor().getName());
    }
}
Java
@Query("SELECT new com.example.bookstore.dto.BookWithAuthorResponse(b) " +
       "FROM Book b JOIN b.author a")
List<BookWithAuthorResponse> findAllSummaries();

Constructor expressions in JPQL fetch only required columns — no lazy-loading risk.

Cascade types

CascadeEffect
PERSISTSaving parent saves children
MERGEUpdating parent updates children
REMOVEDeleting parent deletes children
ALLAll of the above
  • PERSIST

    EffectSaving parent saves children
  • MERGE

    EffectUpdating parent updates children
  • REMOVE

    EffectDeleting parent deletes children
  • ALL

    EffectAll of the above

Use cascade only on compositions (author owns books), not associations (book references a publisher).

Quick recall

Everything you need if you only revisit this box.

  • @ManyToOne holds the foreign key; @OneToMany(mappedBy) is the inverse side.
  • Default fetch type is LAZY — load related data explicitly when needed.
  • N+1 queries happen when lazy relations are accessed in a loop; fix with JOIN FETCH.
  • DTO constructor projections fetch only required columns without loading full entities.
  • cascade and orphanRemoval manage parent-child lifecycle together.
  • Never access lazy collections outside a transactional service method.

Test yourself

Answer these before moving on — recall is what makes it stick.