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;
}
  • mappedBy marks the inverse side - only the owning side writes the foreign key.
  • @ManyToOne defaults to EAGER, which silently joins everything; make it LAZY and fetch explicitly.
  • cascade = ALL plus orphanRemoval = true for 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);
}
ProblemSymptomFix
N+1 on a collection1 query for books, then one per authorjoin fetch in the query
MultipleBagFetchExceptionTwo eager collection fetches in one queryFetch one collection, use @BatchSize for the other
Pagination with a fetch joinHibernate warning about in-memory pagingQuery ids in a page, then fetch details
Over-fetchingEntity graph loads 20 columns for a list screenInterface 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,desc

A 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 size yourself: @PageableDefault does not stop a client sending size=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.

Configuration, JPA data access and profiles Transactions, locking and concurrency

Last refreshed 2026-09-18.