skip to content

With OSIV disabled, how do you correctly fetch data at the transaction boundary so serialization never hits a lazy proxy? Compare the options.

level: seniorimportance: should knowfreq 58%

answer

  1. everything loaded before commit
  2. JOIN FETCH → cartesian / MultipleBagFetchException
  3. @EntityGraph = declarative fetch plan
  4. DTO in tx = entity never escapes
  5. readOnly=true skips dirty check

basics

~20 s

Load everything the response needs while the transaction is still open. Use a JOIN FETCH query or an @EntityGraph to populate associations, or map the entity to a DTO inside the @Transactional method. Then the serializer only sees already-loaded data.

solid answer

~40 s

The rule with OSIV off: the persistence context must have loaded everything the response touches before the transaction commits. Options: (1) JPQL JOIN FETCH — explicit per-query, precise, but risks cartesian products with multiple collections. (2) @EntityGraph on the repository method — declarative fetch plan, keeps the JPQL clean, composes with derived queries. (3) DTO/projection mapping inside the transaction — the strongest choice: you select exactly the columns needed, return an immutable DTO, and the entity/proxies never escape the service, so serialization is guaranteed proxy-free and the API contract is decoupled from the entity model. (4) Hibernate.initialize() to force a specific proxy — a targeted escape hatch. Prefer DTOs for read APIs; use fetch joins/entity graphs when you genuinely need managed entities. Verify with SQL logging or an assert-no-lazy test.

code

java · 26 lines
java
// DTO boundary — the recommended default for read endpoints
public record UserView(Long id, String name, List<OrderView> orders) {}
public record OrderView(Long id, BigDecimal total) {}

@Service
class UserService {

    private final UserRepository repo;
    UserService(UserRepository repo) { this.repo = repo; }

    @Transactional(readOnly = true)
    public UserView getUser(Long id) {
        User u = repo.findById(id).orElseThrow();
        // proxies initialized here, while the Session is still open
        List<OrderView> orders = u.getOrders().stream()
            .map(o -> new OrderView(o.getId(), o.getTotal()))
            .toList();
        return new UserView(u.getId(), u.getName(), orders);
    } // tx commits, Session closes — but we only return a plain DTO
}

interface UserRepository extends JpaRepository<User, Long> {
    // alternative: pull the graph in one shot if you need the managed entity
    @EntityGraph(attributePaths = "orders")
    Optional<User> findWithOrdersById(Long id);
}

go deeper

for a junior

Can name 'fetch it inside the transaction' but may not compare options.

for a middle

Knows JOIN FETCH and @EntityGraph and that DTOs avoid the problem.

for a senior

Compares all options, cites cartesian-product/pagination pitfalls, and defaults to DTO boundaries for reads.

for a principal

Establishes DTO-at-the-boundary as an enforceable convention and adds fail-fast tests/SQL-count assertions to guard N+1.

## The governing principle With `spring.jpa.open-in-view=false`, the Hibernate **Session** closes when the `@Transactional` service method returns. Therefore **every field the HTTP response serializes must already be initialized inside that transaction**. Anything left as a lazy proxy will throw `LazyInitializationException` during serialization. Fetching becomes an explicit, intentional design decision made *at the boundary* between the transactional world and the web/serialization world. ## Option 1 — JPQL `JOIN FETCH` ```java @Query("select u from User u join fetch u.orders where u.id = :id") Optional<User> findWithOrders(@Param("id") Long id); ``` - **Pros**: explicit, per-use-case control; the join happens in one SQL statement. - **Cons**: fetching **two** collections in one query yields a **cartesian product** (Hibernate throws `MultipleBagFetchException` for two `List`s). Combining with pagination on a collection join forces in-memory pagination (Hibernate logs `HHH000104: firstResult/maxResults specified with collection fetch; applying in memory`). ## Option 2 — `@EntityGraph` ```java @EntityGraph(attributePaths = {"orders", "profile"}) Optional<User> findById(Long id); ``` - **Pros**: declarative **fetch plan** separated from the query text; composes with Spring Data derived/`@Query` methods; you can define named graphs on the entity. - **Cons**: same cartesian-product caveats as fetch joins when pulling multiple collections; can become a hidden coupling if overused. ## Option 3 — DTO / projection inside the transaction (usually best for reads) ```java @Transactional(readOnly = true) public UserView getUser(Long id) { User u = repo.findById(id).orElseThrow(); return new UserView( u.getId(), u.getName(), u.getOrders().stream().map(o -> new OrderView(o.getId(), o.getTotal())).toList() ); // proxies initialized here, INSIDE the tx } ``` Or a **constructor expression** / Spring Data **interface projection** so the DB returns only the needed columns: ```java @Query("select new com.app.UserView(u.id, u.name) from User u where u.id = :id") UserView findViewById(@Param("id") Long id); ``` - **Pros**: the entity **never leaves** the service, so serialization physically cannot touch a proxy; you fetch exactly the columns you need; the API contract is decoupled from the persistence model; `readOnly=true` lets Hibernate skip dirty-checking/flush. This is the most robust boundary. - **Cons**: mapping boilerplate (mitigate with MapStruct/records). ## Option 4 — `Hibernate.initialize()` ```java @Transactional(readOnly = true) public User getUser(Long id) { User u = repo.findById(id).orElseThrow(); Hibernate.initialize(u.getOrders()); return u; } ``` Forces a specific proxy to load before returning. A targeted escape hatch, not a general strategy — you still leak a managed entity outward. ## Choosing - **Read/query APIs → DTOs/projections.** Explicit, efficient, proxy-safe, decoupled. - **Need managed entities in the response (rare) → `@EntityGraph`/fetch join**, minding cartesian products. - **One awkward field → `Hibernate.initialize()`.** ## Verifying Enable SQL logging (`spring.jpa.show-sql` / `org.hibernate.SQL=DEBUG`) or Hibernate statistics to count queries and catch N+1. Write a test that serializes the response **outside** any transaction — if a proxy is unmet, it throws, catching the bug at build time. This 'fail-fast' behavior is a key reason teams disable OSIV.

  • Why can't you JOIN FETCH two separate List collections in one query?
    It produces a cartesian product of the two collections; Hibernate refuses with MultipleBagFetchException for two bag (List) fetches. Solutions: use Set collections, split into separate queries, or fetch one collection per query and rely on the same persistence context to stitch them.
  • What happens if you JOIN FETCH a collection while also paginating?
    Hibernate cannot apply the LIMIT at the SQL level correctly (rows are multiplied by the join), so it fetches all rows and paginates in memory, logging HHH000104. Prefer fetching IDs with pagination first, then loading the collection for those IDs.

saying these in an interview costs you the question

  • Recommending FetchType.EAGER on the mapping instead of per-query fetching
  • Returning managed entities from the service and assuming Jackson won't touch proxies
  • Fetch-joining two collections and not knowing about MultipleBagFetchException / cartesian products
  • Not knowing collection-fetch + pagination paginates in memory

context