Skip to content
Commitwise
Menu
Java Backend

JPA and Hibernate

Map entities and relationships, query with Spring Data repositories or Jakarta Persistence, understand lazy loading and the persistence context, and avoid the N+1, LazyInitializationException and pagination traps.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

Spring Data JPA lets you write interface BookRepository extends JpaRepository<Book, Long> and get saving, loading, paging and queries without a line of SQL. That’s wonderful until an endpoint runs 501 queries, a JSON response fails with LazyInitializationException, or a page of 20 results loads 50,000 rows into memory.

Jakarta Persistence (JPA) lets you write em.find(Book.class, 42) and em.persist(book) and get objects in and out of the database without a line of SQL. That’s wonderful until an endpoint runs 501 queries, a JSON response fails with LazyInitializationException, or a page of 20 results loads 50,000 rows into memory.

All three come from the same place: Hibernate doing work for you that you didn’t see. This tutorial shows what it does, so you can decide when to let it.

New to some of these? Open what you need, or skip ahead.

Basics:What are databases, tables and SQL?

A database stores an app’s data so it survives restarts and can be searched quickly. A relational database (PostgreSQL, MySQL) keeps data in tables: rows (one per book, user or order) and columns (id, title, year).

You talk to it with SQL: SELECT title FROM books WHERE year > 2000 reads, INSERT, UPDATE and DELETE change data. Rows refer to other tables through foreign keys (book.author_id points to a row in author), and indexes make lookups by a column fast.

Basics:What do annotations like @Service mean?

An annotation (the @Something in Java and Kotlin) attaches extra information to a class, method, field or parameter. It doesn’t run code by itself; tools and frameworks read it and act on it.

@Service (Spring) or @ApplicationScoped (Jakarta EE) says “create one of these and manage it”, @GetMapping("/books") (Spring) or @GET @Path("/books") (Jakarta EE) says “call this method for GET requests to /books”, and @Entity tells Hibernate “this class is a database table”. Swift has a related idea in attributes and property wrappers, like @State and @MainActor.

Basics:What is Spring Boot?

Spring Boot is the most popular framework for Java and Kotlin backends. You write classes and mark them with annotations; Spring creates the objects (called beans), connects them to each other (dependency injection: a controller gets the service it needs through its constructor) and runs a web server that sends HTTP requests to your methods.

Starters (spring-boot-starter-web, spring-boot-starter-data-jpa…) add features by adding a dependency, with sensible defaults you can change in application.yml.

Basics:What is Jakarta EE?

Jakarta EE (formerly Java EE) is a set of standard APIs for Java backends, each with its own name: Jakarta REST for HTTP endpoints, CDI for creating objects and connecting them (dependency injection), Jakarta Persistence (JPA) for databases, Jakarta Transactions for @Transactional.

You write code against the standard, and a runtime provides the implementation: an application server such as WildFly, Payara or Open Liberty, or a lighter runtime built on the same APIs, such as Quarkus or Helidon. Most of them use Hibernate as their JPA implementation.

Basics:What is an interface (or protocol)?

An interface (Kotlin, Java) or protocol (Swift) is a list of things a type promises it can do, without saying how. For example, “anything that is a PaymentMethod has a pay(amount) function”.

Different classes can implement the same interface in different ways (card, PayPal, cash), and code that only knows the interface can use any of them. That’s what lets you swap one implementation for another, for example a fake one in tests.

Basics:What is JSON?

JSON is the text format most APIs use to send data. Objects are written in braces with "key": value pairs, lists in square brackets:

{ "id": 7, "name": "Mug", "price": 9.5, "tags": ["kitchen", "gift"], "discount": null }

Values can be strings (in quotes), numbers, true/false, null, objects or lists. Turning JSON into your own types is called decoding (or parsing, or deserializing); the opposite is encoding.

Part Not started

Entities, repositories and Hibernate

Think of Hibernate as a translator between two languages: your Java or Kotlin objects, and the database’s tables and rows.

  • An entity is a class that maps to a table: each object is a row, each field a column.
  • A repository is an interface with methods like findById and save. Spring Data writes the implementation for you, and can even build queries from method names like findByAuthorName.
  • The EntityManager is your door to the database: find loads an entity by id, persist saves a new one, and createQuery runs JPQL, a query language like SQL that talks about entities and fields instead of tables and columns.
  • Hibernate is the JPA implementation that turns those calls into SQL, and turns the results back into objects.

The magic has rules, and most bugs come from not knowing them: which related objects are loaded when, and which changes are saved automatically.

JPA maps entities to tables; Hibernate implements it with a persistence context (a first-level cache and unit of work per transaction) that tracks loaded entities, writes back changes on flush (dirty checking), and loads associations through proxies lazily or eagerly. Spring Data generates repositories, derived queries, paging and projections on top.

The defaults are where production problems start: @ManyToOne and @OneToOne are eager by default, collection associations are lazy and fail outside a session, open-in-view hides that by keeping sessions open during JSON rendering, and collection fetch joins can’t be paginated in SQL. The Detailed version covers fetch planning, batch fetching, projections, entity equality and Kotlin-specific pitfalls.

JPA maps entities to tables; Hibernate implements it with a persistence context (a first-level cache and unit of work, scoped to the JTA transaction for an injected EntityManager) that tracks loaded entities, writes back changes on flush (dirty checking), and loads associations through proxies lazily or eagerly. Queries are JPQL or the Criteria API; Jakarta EE 11 adds Jakarta Data repositories.

The defaults are where production problems start: @ManyToOne and @OneToOne are eager by default, collection associations are lazy and fail once the persistence context has closed (right after the call, when there’s no transaction), and collection fetch joins can’t be paginated in SQL. The Detailed version covers fetch planning, batch fetching, projections, entity equality and Kotlin-specific pitfalls.

@Entity
class Author(
    var name: String,
    @Id @GeneratedValue var id: Long? = null,
) {
    @OneToMany(mappedBy = "author")            // lazy by default
    val books: MutableList<Book> = mutableListOf()
}

@Entity
class Book(
    var title: String,
    var year: Int,
    @ManyToOne(fetch = FetchType.LAZY)         // eager by default: make it lazy
    @JoinColumn(name = "author_id")
    var author: Author,
    @Id @GeneratedValue var id: Long? = null,
)

interface BookRepository : JpaRepository<Book, Long> {
    fun findByAuthorNameOrderByYearDesc(authorName: String): List<Book>
    fun findByYearGreaterThan(year: Int, pageable: Pageable): Page<Book>
}
@Entity
public class Author {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "author")            // lazy by default
    private List<Book> books = new ArrayList<>();

    // getters, setters, protected no-arg constructor …
}

@Entity
public class Book {
    @Id @GeneratedValue
    private Long id;
    private String title;
    private int year;

    @ManyToOne(fetch = FetchType.LAZY)         // eager by default: make it lazy
    @JoinColumn(name = "author_id")
    private Author author;

    // getters, setters, protected no-arg constructor …
}

public interface BookRepository extends JpaRepository<Book, Long> {
    List<Book> findByAuthorNameOrderByYearDesc(String authorName);   // SQL generated from the name
    Page<Book> findByYearGreaterThan(int year, Pageable pageable);     // paging and sorting
}

Three things to notice:

@Entity
public class Author {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "author")            // lazy by default
    private List<Book> books = new ArrayList<>();

    // getters, setters, protected no-arg constructor …
}

@Entity
public class Book {
    @Id @GeneratedValue
    private Long id;
    private String title;
    private int year;

    @ManyToOne(fetch = FetchType.LAZY)         // eager by default: make it lazy
    @JoinColumn(name = "author_id")
    private Author author;

    // getters, setters, protected no-arg constructor …
}

@ApplicationScoped
public class BookQueries {

    @PersistenceContext
    EntityManager em;

    public List<Book> byAuthorName(String authorName) {
        return em.createQuery("""
                select b from Book b join b.author a
                where a.name = :name
                order by b.year desc
                """, Book.class)
            .setParameter("name", authorName)
            .getResultList();
    }

    public List<Book> newerThan(int year, int page, int size) {   // paging
        return em.createQuery("select b from Book b where b.year > :year order by b.id", Book.class)
            .setParameter("year", year)
            .setFirstResult(page * size)
            .setMaxResults(size)
            .getResultList();
    }
}

Kotlin note

Jakarta EE examples are in Java, the language almost all Jakarta EE code is written in. The same annotations work from Kotlin with the kotlin-jpa (no-arg) and allopen compiler plugins, configured for @Entity and the CDI scopes.

Three things to notice:

Dev note: Repositories in Jakarta EE 11
Jakarta Data brings Spring-Data-style repositories to the standard: @Repository public interface Books extends CrudRepository<Book, Long> gets findById, save and delete, and @Query("where author.name = :name order by year desc") methods, implemented by the runtime. On older versions, and in most existing code, the EntityManager is used directly as here.

Kotlin note

Kotlin classes are final and have no no-argument constructor, and Hibernate needs both for proxies and instantiation. The kotlin("plugin.jpa") (no-arg) and kotlin("plugin.spring") / allopen Gradle plugins generate them; configure allOpen for @Entity too, or lazy @ManyToOne proxies silently become eager.

Changes save themselves

Inside a transaction, Hibernate remembers every entity it loaded. When the transaction commits, it compares each one with how it was loaded, and writes the differences:

Predict the output

Book 1 is titled 1984. What does the second request print?

@Service
class BookService(private val books: BookRepository) {
    @Transactional
    fun rename(id: Long, title: String) {
        val book = books.findById(id).orElseThrow()
        book.title = title                    // no save() call
    }

    @Transactional(readOnly = true)
    fun title(id: Long): String = books.findById(id).orElseThrow().title
}

bookService.rename(1, "Animal Farm")          // request 1
println(bookService.title(1))                 // request 2
@Service
public class BookService {
    @Transactional
    public void rename(long id, String title) {
        Book book = books.findById(id).orElseThrow();
        book.setTitle(title);                 // no save() call
    }

    @Transactional(readOnly = true)
    public String title(long id) {
        return books.findById(id).orElseThrow().getTitle();
    }
}

bookService.rename(1, "Animal Farm");         // request 1
System.out.println(bookService.title(1));     // request 2

Predict the output

Book 1 is titled 1984. What does the second request print?

@ApplicationScoped
public class BookService {
    @PersistenceContext EntityManager em;

    @Transactional
    public void rename(long id, String title) {
        Book book = em.find(Book.class, id);
        book.setTitle(title);                 // no persist() or merge() call
    }

    public String title(long id) {
        return em.find(Book.class, id).getTitle();
    }
}

bookService.rename(1, "Animal Farm");         // request 1
System.out.println(bookService.title(1));     // request 2

Practice: a two-sided relationship

An author has books, and a book has an author. In the database there’s one column (book.author_id); in Java there are two references. Step by step:

  1. Pick the owner: the side with the foreign key, Book.author (@ManyToOne + @JoinColumn). Only the owner’s value is written to the database.
  2. Mark the mirror: @OneToMany(mappedBy = "author") on Author.books. It’s for reading and navigation only.
  3. Keep both sides in sync in your Java objects with a helper method, so code that reads author.getBooks() in the same transaction sees the new book.
  4. Decide cascades deliberately: cascade = CascadeType.ALL and orphanRemoval = true only for true parts of a whole (order lines of an order), not for independent entities (books of an author).
@Entity
class Author(var name: String, @Id @GeneratedValue var id: Long? = null) {
    @OneToMany(mappedBy = "author")
    private val _books: MutableList<Book> = mutableListOf()
    val books: List<Book> get() = _books

    fun addBook(book: Book) {
        _books += book
        book.author = this                    // the owning side: this is what gets saved
    }
}
@Entity
public class Author {
    // …
    @OneToMany(mappedBy = "author")
    private List<Book> books = new ArrayList<>();

    public void addBook(Book book) {
        books.add(book);
        book.setAuthor(this);                 // the owning side: this is what gets saved
    }

    public void removeBook(Book book) {
        books.remove(book);
        book.setAuthor(null);
    }
}

Here’s what happens when the owner is forgotten:

Spot the bugBooks without an author

Tap the line with the bug.

@Transactional
fun importBooks(authorId: Long, titles: List<String>) {
    val author = authors.findById(authorId).orElseThrow()
    for (title in titles) {
        val book = Book(title)                    // author is a nullable var
        author.books.add(book)
        books.save(book)
    }
}
@Transactional
public void importBooks(long authorId, List<String> titles) {
    Author author = authors.findById(authorId).orElseThrow();
    for (String title : titles) {
        Book book = new Book(title);
        author.getBooks().add(book);
        books.save(book);
    }
}

Spot the bugBooks without an author

Tap the line with the bug.

@Transactional
public void importBooks(long authorId, List<String> titles) {
    Author author = em.find(Author.class, authorId);
    for (String title : titles) {
        Book book = new Book(title);
        author.getBooks().add(book);
        em.persist(book);
    }
}

The persistence context in practice

  • Managed entities are flushed automatically before queries that might see them and at commit; readOnly = true transactions skip dirty checking (and Hibernate can skip snapshot copies).
  • Bulk updates (@Modifying @Query("update …")) go straight to the database and bypass the persistence context: already-loaded entities are now stale. Use clearAutomatically = true or run them in their own transaction.
  • Managed entities are flushed automatically before queries that might see them and at commit. Jakarta’s @Transactional has no read-only flag; for big read-only loads, use DTO projections, or the org.hibernate.readOnly query hint so Hibernate skips snapshots and dirty checking.
  • Bulk updates (em.createQuery("update …").executeUpdate()) go straight to the database and bypass the persistence context: already-loaded entities are now stale. Call em.clear() afterwards, or run them in their own transaction.
  • Long transactions that load thousands of entities keep all of them (plus snapshots) in memory; process large sets in chunks with clear(), or use a stream with a fetch size and detach as you go.
  • Entity identity: equals/hashCode based on a generated id break when the id is null before persisting (an entity changes hash after save, losing it from HashSets). Use a natural business key, or the id with a constant hashCode(), and never a Kotlin data class for entities.

The toString that loads the database

Predict the output

What happens when this Kotlin code logs the author?

@Entity
data class Author(
    @Id @GeneratedValue val id: Long? = null,
    val name: String = "",
    @OneToMany(mappedBy = "author") val books: MutableList<Book> = mutableListOf(),
)

@Entity
data class Book(
    @Id @GeneratedValue val id: Long? = null,
    val title: String = "",
    @ManyToOne(fetch = FetchType.LAZY) val author: Author? = null,
)

@Transactional
fun show(id: Long) {
    val author = authors.findById(id).orElseThrow()
    log.info("Loaded {}", author)
}
// Lombok's @Data on entities causes the same problem in Java:
// it generates toString(), equals() and hashCode() over every field, including lazy associations.
@Entity @Data
public class Author {
    @Id @GeneratedValue private Long id;
    private String name;
    @OneToMany(mappedBy = "author") private List<Book> books = new ArrayList<>();
}

@Transactional
public void show(long id) {
    Author author = authors.findById(id).orElseThrow();
    log.info("Loaded {}", author);
}

Deep dives

Deep diveDirty checking: why there's no save() callInterview favorite

Entities loaded inside a transaction are managed by the persistence context, which keeps a snapshot of their state. At flush time (before commit, and before queries that might see the changes), Hibernate compares every managed entity with its snapshot and issues an UPDATE for the ones that changed. That’s why order.status = SHIPPED inside a transaction is saved without calling save().

The costs follow: memory for the snapshots and CPU for the comparison grow with the number of loaded entities, so a transaction that loads 50,000 rows to read them pays for dirty checking it never needed. Read-only transactions (Spring’s @Transactional(readOnly = true), Hibernate read-only sessions) can skip snapshots, and bulk updates belong in a single UPDATE query.

  • The persistence context is a first-level cache and unit of work: loaded entities are tracked, compared at flush (dirty checking) and written without save(). Large transactions pay for it in memory; process in chunks and clear.
  • Only the owning side (with @JoinColumn) writes the foreign key; keep both sides in sync with helper methods. No data classes or @Data for entities: generated equals/toString touch lazy associations.

Check this part

  1. Inside a transaction, you load a book and change its title without calling save() or persist(). What happens?

  2. In a bidirectional relationship, which side does Hibernate use to write the foreign key?

  3. Why avoid Kotlin data classes (or Lombok @Data) for entities?

  4. A job loads 200,000 entities in one transaction to read them. What does the persistence context cost?

Entities map classes to tables, repositories or the EntityManager load and save them, and the persistence context tracks every loaded entity: changes inside a transaction are written at commit, and relationships have one owning side.

Part Not started

Lazy data after the session is gone

Spot the bugAn order endpoint that fails on the JSON step

Tap the line with the bug.

@Service
class OrderService(private val orders: OrderRepository) {
    fun find(id: Long): Order = orders.findById(id).orElseThrow()
}

@GetMapping("/orders/{id}")
fun get(@PathVariable id: Long): OrderResponse {
    val order = orderService.find(id)
    return OrderResponse(order.id!!, order.lines.size)
}
@Service
public class OrderService {
    public Order find(long id) {
        return orders.findById(id).orElseThrow();
    }
}

@GetMapping("/orders/{id}")
OrderResponse get(@PathVariable long id) {
    Order order = orderService.find(id);
    return new OrderResponse(order.getId(), order.getLines().size());
}

Spot the bugAn order endpoint that fails on the JSON step

Tap the line with the bug.

@ApplicationScoped
public class OrderService {
    @PersistenceContext EntityManager em;

    public Order find(long id) {
        return em.find(Order.class, id);
    }
}

@GET @Path("/orders/{id}")
public OrderResponse get(@PathParam("id") long id) {
    Order order = orderService.find(id);
    return new OrderResponse(order.getId(), order.getLines().size());
}

Exerciseeasy

A paged list of books

Build GET /api/books?author=orwell&page=0&size=20&sort=year,desc that returns books whose author’s name contains the query (case-insensitive), with paging and sorting, as DTOs with only id, title, year and authorName.

It must run a fixed number of queries, however many books are on the page.

›

Spring Data turns Pageable parameters (page, size, sort) into a Pageable argument automatically. A derived query can take it as its last parameter.

Read page and size with @QueryParam and @DefaultValue, and apply them with setFirstResult(page * size) and setMaxResults(size). For a total, run a second select count(…) query.

›

Returning entities and reading book.getAuthor().getName() for each one runs a query per book.

Select the DTO directly in JPQL with a join, or use an interface projection.

Select the DTO directly in JPQL with a join: select new com.example.books.BookSummary(…).

SolutionTry it yourself first
data class BookSummary(val id: Long, val title: String, val year: Int, val authorName: String)

interface BookRepository : JpaRepository<Book, Long> {
    @Query(
        value = """
            select new com.example.books.BookSummary(b.id, b.title, b.year, a.name)
            from Book b join b.author a
            where lower(a.name) like lower(concat('%', :author, '%'))
        """,
        countQuery = """
            select count(b) from Book b join b.author a
            where lower(a.name) like lower(concat('%', :author, '%'))
        """,
    )
    fun searchByAuthor(@Param("author") author: String, pageable: Pageable): Page<BookSummary>
}

@GetMapping("/api/books")
fun books(
    @RequestParam author: String,
    @PageableDefault(size = 20, sort = ["year"], direction = Sort.Direction.DESC) pageable: Pageable,
): Page<BookSummary> = bookRepository.searchByAuthor(author, pageable)
public record BookSummary(long id, String title, int year, String authorName) {}

public interface BookRepository extends JpaRepository<Book, Long> {
    @Query(value = """
            select new com.example.books.BookSummary(b.id, b.title, b.year, a.name)
            from Book b join b.author a
            where lower(a.name) like lower(concat('%', :author, '%'))
            """,
           countQuery = """
            select count(b) from Book b join b.author a
            where lower(a.name) like lower(concat('%', :author, '%'))
            """)
    Page<BookSummary> searchByAuthor(@Param("author") String author, Pageable pageable);
}

@GetMapping("/api/books")
Page<BookSummary> books(@RequestParam String author,
                        @PageableDefault(size = 20, sort = "year", direction = Sort.Direction.DESC) Pageable pageable) {
    return bookRepository.searchByAuthor(author, pageable);
}

Exactly two queries per request: one for the page, one for the total count. The DTO projection selects only four columns and creates no managed entities, so there’s no dirty checking and no lazy loading to worry about. Sorting by year works because the sort property is resolved against the query’s root entity (b.year). In production, return your own page DTO rather than Spring’s Page, whose JSON shape isn’t a stable contract, and know that like '%…%' can’t use a normal index: for large tables, use full-text search.

public record BookSummary(long id, String title, int year, String authorName) {}
public record PageResponse<T>(List<T> items, int page, int size, long total) {}

@GET
@Path("/api/books")
public PageResponse<BookSummary> books(@QueryParam("author") String author,
                                       @QueryParam("page") @DefaultValue("0") int page,
                                       @QueryParam("size") @DefaultValue("20") int size) {
    String pattern = "%" + author.toLowerCase() + "%";
    List<BookSummary> items = em.createQuery("""
            select new com.example.books.BookSummary(b.id, b.title, b.year, a.name)
            from Book b join b.author a
            where lower(a.name) like :pattern
            order by b.year desc
            """, BookSummary.class)
        .setParameter("pattern", pattern)
        .setFirstResult(page * size)
        .setMaxResults(Math.min(size, 50))
        .getResultList();
    long total = em.createQuery(
            "select count(b) from Book b join b.author a where lower(a.name) like :pattern", Long.class)
        .setParameter("pattern", pattern)
        .getSingleResult();
    return new PageResponse<>(items, page, size, total);
}

Exactly two queries per request: one for the page, one for the total count. The DTO projection selects only four columns and creates no managed entities, so there’s no dirty checking and no lazy loading to worry about. The page size is capped on the server, so nobody can ask for a million rows. And know that like '%…%' can’t use a normal index: for large tables, use full-text search.

Fetch planning

Lazy loading is a default, not a strategy. Decide per use case what to load:

  • Make every @ManyToOne/@OneToOne lazy. The eager default loads associations you didn’t ask for, often with extra queries, and can’t be turned off per query (a fetch join can make lazy eager, nothing can make eager lazy).
  • Fetch joins (join fetch) or @EntityGraph(attributePaths = ...) load exactly the associations one query needs.
  • Fetch joins (join fetch) or entity graphs (@NamedEntityGraph, passed as the jakarta.persistence.fetchgraph hint to em.find or a query) load exactly the associations one query needs.

Paging a fetch join

Spot the bugPage 1 of 20 authors, 50,000 rows loaded

Tap the line with the bug.

interface AuthorRepository : JpaRepository<Author, Long> {
    @Query("select distinct a from Author a left join fetch a.books")
    fun findAllWithBooks(pageable: Pageable): List<Author>
}

val page = authorRepository.findAllWithBooks(PageRequest.of(0, 20))
public interface AuthorRepository extends JpaRepository<Author, Long> {
    @Query("select distinct a from Author a left join fetch a.books")
    List<Author> findAllWithBooks(Pageable pageable);
}

List<Author> page = authorRepository.findAllWithBooks(PageRequest.of(0, 20));

Spot the bugPage 1 of 20 authors, 50,000 rows loaded

Tap the line with the bug.

List<Author> page = em.createQuery(
        "select distinct a from Author a left join fetch a.books order by a.name", Author.class)
    .setFirstResult(0)
    .setMaxResults(20)
    .getResultList();

Exercisemedium

Order history at scale

GET /api/me/orders?page=0&size=10 shows a customer’s orders, newest first. Each order shows its date, status, total, and for each line the product name, quantity and price. Customers can have thousands of orders; orders have 1 to 50 lines.

The current implementation returns Page<Order> entities serialized with Jackson, and the database team says it runs about 300 queries per request. Redesign the data access: queries, mapping and response shape.

›

The 300 queries come from lazy loading during serialization: lines per order, then product per line. Count how many you get for 10 orders with 10 lines each.

›

Page the orders alone (a cheap indexed query), then load the lines with product names for exactly those order ids in one query, and assemble DTOs in memory.

SolutionTry it yourself first
@Service
class OrderHistoryService(private val orders: OrderRepository) {
    @Transactional(readOnly = true)
    fun history(customerId: Long, page: Int, size: Int): PageResponse<OrderView> {
        val headers = orders.findHeaders(
            customerId, PageRequest.of(page, size.coerceAtMost(50), Sort.by(Sort.Direction.DESC, "placedAt")),
        )
        val linesByOrder = orders.findLines(headers.content.map { it.id }).groupBy { it.orderId }
        val views = headers.content.map { h ->
            OrderView(h.id, h.placedAt, h.status, h.totalCents, linesByOrder[h.id].orEmpty())
        }
        return PageResponse(views, page, size, headers.totalElements)
    }
}
public record OrderLineView(long orderId, String productName, int quantity, long priceCents) {}
public record OrderView(long id, Instant placedAt, String status, long totalCents, List<OrderLineView> lines) {}

public interface OrderRepository extends JpaRepository<Order, Long> {
    // 1. One page of orders: index on (customer_id, placed_at desc)
    @Query("""
        select o.id as id, o.placedAt as placedAt, o.status as status, o.totalCents as totalCents
        from Order o where o.customer.id = :customerId
        """)
    Page<OrderHeader> findHeaders(@Param("customerId") long customerId, Pageable pageable);

    // 2. All lines for those orders, with product names, in one query
    @Query("""
        select new com.example.orders.OrderLineView(l.order.id, p.name, l.quantity, l.priceCents)
        from OrderLine l join l.product p
        where l.order.id in :orderIds
        order by l.order.id, l.position
        """)
    List<OrderLineView> findLines(@Param("orderIds") Collection<Long> orderIds);
}

public interface OrderHeader { long getId(); Instant getPlacedAt(); String getStatus(); long getTotalCents(); }

@Service
public class OrderHistoryService {
    @Transactional(readOnly = true)
    public PageResponse<OrderView> history(long customerId, int page, int size) {
        Page<OrderHeader> headers = orders.findHeaders(customerId,
            PageRequest.of(page, Math.min(size, 50), Sort.by(Sort.Direction.DESC, "placedAt")));
        Map<Long, List<OrderLineView>> lines = orders.findLines(
                headers.map(OrderHeader::getId).getContent())
            .stream().collect(Collectors.groupingBy(OrderLineView::orderId));

        List<OrderView> views = headers.map(h -> new OrderView(h.getId(), h.getPlacedAt(), h.getStatus(),
            h.getTotalCents(), lines.getOrDefault(h.getId(), List.of()))).getContent();
        return new PageResponse<>(views, page, size, headers.getTotalElements());
    }
}

Before: 1 page query + 1 count + 10 lazy lines loads + ~100 lazy product loads (plus anything else Jackson touched) on a session kept open by open-in-view.

public record OrderHeader(long id, Instant placedAt, String status, long totalCents) {}
public record OrderLineView(long orderId, String productName, int quantity, long priceCents) {}
public record OrderView(long id, Instant placedAt, String status, long totalCents, List<OrderLineView> lines) {}

@ApplicationScoped
public class OrderHistoryService {
    @PersistenceContext EntityManager em;

    @Transactional
    public PageResponse<OrderView> history(long customerId, int page, int size) {
        int limit = Math.min(size, 50);
        // 1. One page of orders: index on (customer_id, placed_at desc)
        List<OrderHeader> headers = em.createQuery("""
                select new com.example.orders.OrderHeader(o.id, o.placedAt, o.status, o.totalCents)
                from Order o where o.customer.id = :customerId
                order by o.placedAt desc
                """, OrderHeader.class)
            .setParameter("customerId", customerId)
            .setFirstResult(page * limit)
            .setMaxResults(limit)
            .getResultList();
        long total = em.createQuery("select count(o) from Order o where o.customer.id = :customerId", Long.class)
            .setParameter("customerId", customerId)
            .getSingleResult();

        // 2. All lines for those orders, with product names, in one query
        Map<Long, List<OrderLineView>> lines = em.createQuery("""
                select new com.example.orders.OrderLineView(l.order.id, p.name, l.quantity, l.priceCents)
                from OrderLine l join l.product p
                where l.order.id in :orderIds
                order by l.order.id, l.position
                """, OrderLineView.class)
            .setParameter("orderIds", headers.stream().map(OrderHeader::id).toList())
            .getResultList()
            .stream().collect(Collectors.groupingBy(OrderLineView::orderId));

        List<OrderView> views = headers.stream().map(h -> new OrderView(h.id(), h.placedAt(), h.status(),
            h.totalCents(), lines.getOrDefault(h.id(), List.of()))).toList();
        return new PageResponse<>(views, page, limit, total);
    }
}

Before: 1 page query + 1 count + 10 lazy lines loads + ~100 lazy product loads (plus anything else the JSON binding touched).

After: 3 queries (page, count, lines), independent of page size, each using an index: (customer_id, placed_at) on orders and order_id on lines.

Further decisions to mention:

  • Store the product name and price on the order line at purchase time. History must show what the customer bought then, not the product’s current name and price, and it removes the product join entirely.
  • Skip the count on deep pages (a Slice in Spring Data, or just fetch size + 1 rows to know if there’s a next page), or use keyset pagination (where placed_at < :lastSeen), which stays fast at page 500 where OFFSET doesn’t.
  • Cap size server-side, and return your own page DTO.
  • Test the query count: an integration test with Testcontainers asserting at most 3 statements per request, so the next lazy access added to a mapper fails CI instead of the database.
ConcernPractice
Associationsall @ManyToOne/@OneToOne LAZY; collections lazy; owner = foreign-key side
Loading per use casejoin fetch, @EntityGraph, DTO projections (select new …, interface projections)
Safety netdefault_batch_fetch_size=50; statement-count assertions in tests
Sessionsspring.jpa.open-in-view=false; map to DTOs inside @Transactional(readOnly = true)
Pagingnever page a collection fetch join (fail_on_pagination_over_collection_fetch=true); page ids, then fetch
Writesdirty checking at flush; bulk @Modifying bypasses the context (clearAutomatically)
Entitiesno Kotlin data class, no Lombok @Data; stable equals/hashCode; Kotlin plugin.jpa + allOpen

Gotchas: eager @ManyToOne by default, LazyInitializationException outside transactions, N+1 hidden by open-in-view, writes through the mirror side are ignored, managed entities save changes you didn’t mean to save.

interface OrderRepository : JpaRepository<Order, Long> {
    @EntityGraph(attributePaths = ["lines", "lines.product"])
    fun findWithLinesById(id: Long): Order?                   // one query for the detail screen

    @Query("select new com.example.OrderRow(o.id, o.placedAt, o.totalCents) from Order o where o.customer.id = :c")
    fun findRows(@Param("c") customerId: Long, pageable: Pageable): Slice<OrderRow>
}
public interface OrderRepository extends JpaRepository<Order, Long> {
    @EntityGraph(attributePaths = {"lines", "lines.product"})
    Optional<Order> findWithLinesById(Long id);              // one query for the detail screen

    @Query("select new com.example.OrderRow(o.id, o.placedAt, o.totalCents) from Order o where o.customer.id = :c")
    Slice<OrderRow> findRows(@Param("c") long customerId, Pageable pageable);   // list screen: DTOs only
}
ConcernPractice
Associationsall @ManyToOne/@OneToOne LAZY; collections lazy; owner = foreign-key side
Loading per use casejoin fetch, @NamedEntityGraph + jakarta.persistence.fetchgraph hint, select new … DTOs
Safety nethibernate.default_batch_fetch_size=50; statement-count assertions in tests
Persistence contexttransaction-scoped: lazy access outside @Transactional fails; map to DTOs inside the transaction
PagingsetFirstResult / setMaxResults; never on a collection fetch join (fail_on_pagination_over_collection_fetch=true); page ids, then fetch
Writesdirty checking at flush; bulk executeUpdate() bypasses the context (em.clear())
RepositoriesEntityManager + JPQL, or Jakarta Data (EE 11) CrudRepository + @Query
Entitiesno Kotlin data class, no Lombok @Data; stable equals/hashCode; Kotlin plugin.jpa + allOpen

Gotchas: eager @ManyToOne by default, LazyInitializationException outside transactions, writes through the mirror side are ignored, managed entities save changes you didn’t mean to save.

@Entity
@NamedEntityGraph(name = "Order.withLines",
    attributeNodes = @NamedAttributeNode(value = "lines", subgraph = "line"),
    subgraphs = @NamedSubgraph(name = "line", attributeNodes = @NamedAttributeNode("product")))
public class Order { … }

// one query for the detail screen
Order order = em.find(Order.class, id,
    Map.of("jakarta.persistence.fetchgraph", em.getEntityGraph("Order.withLines")));

// list screen: DTOs only
List<OrderRow> rows = em.createQuery(
        "select new com.example.OrderRow(o.id, o.placedAt, o.totalCents) from Order o where o.customer.id = :c",
        OrderRow.class)
    .setParameter("c", customerId).setMaxResults(20).getResultList();

Check this part

  1. What does lazy loading mean for author.getBooks()?

  2. Why does order.getLines() throw LazyInitializationException in a controller?

  3. Why is @ManyToOne(fetch = LAZY) recommended?

  4. A collection join fetch with paging returns 20 authors. What does Hibernate actually do?

Lazy associations load on first use and need an open persistence context, so load what an endpoint needs inside the service and return DTOs. Plan fetching per use case to avoid N+1 queries, and never page a collection fetch join.

Key takeaways

  • Entities map classes to tables; query them with JPQL, or let Spring Data (or Jakarta Data) generate the queries.
  • Lazy associations load on first use and need an open transaction; return DTOs from @Transactional service methods.
  • In two-sided relationships, set the owning side (the one with the foreign key), preferably through helper methods.
  • Changes to managed entities are saved automatically at commit; select DTOs for read-only lists.
  • Plan fetching per use case: lazy everywhere, then join fetches, entity graphs, projections and batch fetching.
  • Keep lazy loading inside transactions (in Spring, turn off open-in-view) and assert statement counts in tests, so N+1 fails CI instead of production.
  • Never paginate a collection fetch join; page ids first.
  • Keep entities plain: no data classes or @Data, stable equality, and awareness that bulk updates bypass the persistence context.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.