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.
One-to-many: Author and Books
@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
@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 type | Behaviour | Risk |
|---|---|---|
| LAZY | Loads on first access | LazyInitializationException outside a transaction |
| EAGER | Loads with parent | N+1 queries, over-fetching |
LAZY
BehaviourLoads on first accessRiskLazyInitializationException outside a transactionEAGER
BehaviourLoads with parentRiskN+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. UsemappedBy, 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:
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:
@Query("SELECT b FROM Book b JOIN FETCH b.author")
List<Book> findAllWithAuthor();
Or use @EntityGraph:
@EntityGraph(attributePaths = {"author"})
List<Book> findAll();
Many-to-many: Books and Tags
@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:
public record BookWithAuthorResponse(
Long id, String title, String authorName
) {
public BookWithAuthorResponse(Book book) {
this(book.getId(), book.getTitle(), book.getAuthor().getName());
}
}
@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
| Cascade | Effect |
|---|---|
| PERSIST | Saving parent saves children |
| MERGE | Updating parent updates children |
| REMOVE | Deleting parent deletes children |
| ALL | All of the above |
PERSIST
EffectSaving parent saves childrenMERGE
EffectUpdating parent updates childrenREMOVE
EffectDeleting parent deletes childrenALL
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.
@ManyToOneholds 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.
cascadeandorphanRemovalmanage 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.