JPA relationships, queries and pagination
Mapping associations, choosing fetch strategies, writing JPQL, projecting DTOs, exposing Pageable, and killing N+1 queries.
Mapping associations
@Entity
public class Author {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
private String name;
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books = new ArrayList<>();
// keep both sides consistent
public void addBook(Book b) { books.add(b); b.setAuthor(this); }
}
@Entity
public class Book {
@Id @GeneratedValue Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "author_id")
private Author author;
}mappedBymarks the inverse side - only the owning side writes the foreign key.@ManyToOnedefaults to EAGER, which silently joins everything; make it LAZY and fetch explicitly.cascade = ALLplusorphanRemoval = truefor a true parent-child relationship, never for a shared reference.
⚠️
spring.jpa.open-in-view defaults to true, keeping the session open for the whole request and letting lazy loading happen during JSON serialisation. That hides N+1 and turns a clean layering mistake into a performance cliff. Disable it and fetch what you need in the repository.Queries and projections
public interface BookRepository extends JpaRepository<Book, Long> {
// derived query
List<Book> findByAuthorNameIgnoreCase(String name);
// fetch join prevents N+1 on the collection
@Query("""
select distinct b from Book b
join fetch b.author
left join fetch b.tags
where b.publishedAt >= :since
""")
List<Book> findRecentWithDetails(@Param("since") Instant since);
// interface projection: only the columns the screen needs
interface BookSummary { Long getId(); String getTitle(); String getAuthorName(); }
@Query("select b.id as id, b.title as title, a.name as authorName "
+ "from Book b join b.author a")
Page<BookSummary> findSummaries(Pageable pageable);
}| Problem | Symptom | Fix |
|---|---|---|
| N+1 on a collection | 1 query for books, then one per author | join fetch in the query |
| MultipleBagFetchException | Two eager collection fetches in one query | Fetch one collection, use @BatchSize for the other |
| Pagination with a fetch join | Hibernate warning about in-memory paging | Query ids in a page, then fetch details |
| Over-fetching | Entity graph loads 20 columns for a list screen | Interface or DTO projection |
Pagination and sorting from the controller
@GetMapping("/books")
Page<BookSummary> list(@PageableDefault(size = 20, sort = "title") Pageable pageable) {
return repo.findSummaries(pageable);
}
// GET /books?page=0&size=20&sort=publishedAt,descA Page serialises to content, totalElements, totalPages and number. The count query is a real second query - for large tables expose Slice instead, which only asks whether a next page exists.
- Cap
sizeyourself:@PageableDefaultdoes not stop a client sendingsize=100000. - Always sort by a unique column last, or rows can repeat across pages when a non-unique sort key ties.
FAQ
Why does my query return duplicate books after a join fetch?
A join against a collection multiplies rows. Add
distinct to the JPQL, or switch the association to Set with proper equals/hashCode.Should entities be returned from controllers?
No. Serialising entities couples your API to the schema, triggers lazy loading, and can leak fields. Map to a record DTO or a projection, which also documents the exact contract.
Related
Configuration, JPA data access and profiles Transactions, locking and concurrency
Last refreshed 2026-09-18.