GAP·MAP

Spring Data JPA and Hibernate

backend · Javamiddlesenior~8 min read

covers persistence context & entity states · lazy vs eager & LazyInitializationException · N+1: fetch joins, entity graphs, batch size · @Transactional readOnly & OSIV · first & second level cache · repositories & projections

[ middle depth ]

Why an UPDATE happens without calling save

Load an entity inside an open transaction, change a field, and the row updates at commit even though you never called save(). That surprises people, and it is the single most important thing to understand about JPA.

An entity loaded through an EntityManager (or a Spring Data repository) is managed: it belongs to the persistence context for the length of the transaction. Hibernate keeps a snapshot of the values it read. Before the transaction commits it runs a flush, compares each managed entity against its snapshot, and issues an UPDATE for whatever changed. That comparison is dirty checking.

@Transactional
public void rename(Long id, String email) {
    User user = userRepository.findById(id).orElseThrow();
    user.setEmail(email);   // managed entity, mutated in memory
    // no save() call: the UPDATE is emitted at flush, before commit
}

Wrong "You must call save() or the change is lost."

Right "A managed entity is write-behind. save() matters for a brand new (transient) entity that Hibernate has never seen, or to re-attach a detached one. For an entity already managed in this transaction, dirty checking handles the update."

An entity moves through four states. It starts transient (a plain new object Hibernate knows nothing about), becomes managed when you persist or load it, becomes detached once the persistence context closes, and becomes removed when you delete it.

transientmanagedremoveddetachedpersist / findByIdremove / deletetransaction endsmerge
fig · JPA entity lifecycle

The detached state is where bugs live: a detached entity is a normal object, so mutating it does nothing to the database until you merge it back into a persistence context.

Lazy vs eager, and when LazyInitializationException fires

Each association has a fetch type. Under JPA defaults, @ManyToOne and @OneToOne are EAGER (loaded with the owner), while @OneToMany and @ManyToMany collections are LAZY (loaded on first access). A lazy association starts life as a proxy or an uninitialized collection; touching it triggers a SELECT, but only if the persistence context is still open.

Access a lazy association after the transaction has committed and the context has closed, and Hibernate throws LazyInitializationException. This is the number-one Hibernate error in web apps: the service returns an entity, the transaction ends, and the controller or the JSON serializer walks a lazy relationship that can no longer load.

@Transactional(readOnly = true)
public Order load(Long id) {
    return orderRepository.findById(id).orElseThrow();
}
// later, outside the transaction:
order.getLineItems().size();   // LazyInitializationException

Wrong "Switch the association to EAGER and the exception goes away for good."

Right "Fetch the data you actually need before the context closes. Making everything eager trades one problem for slower queries and, as the senior notes cover, its own failure modes."

The N+1 problem in JPA, and fetching the graph you need

The general N+1 story (one query for the parents, then one per parent for a relationship) is covered in orm-and-n-plus-1. In JPA the concrete trigger is a lazy collection accessed in a loop:

List<Order> orders = orderRepository.findAll();   // 1 query
for (Order o : orders) {
    total += o.getLineItems().size();              // N more queries
}

Two per-query tools fix it. A fetch join in JPQL loads the collection with its parents in one statement, and an @EntityGraph on a repository method does the same declaratively:

@Query("select o from Order o join fetch o.lineItems where o.status = :s")
List<Order> findByStatusWithLines(@Param("s") Status s);

@EntityGraph(attributePaths = "lineItems")
List<Order> findByStatus(Status s);

Both are per use case: the endpoint that needs line items asks for them, the one that does not stays cheap. That is the difference from EAGER, which forces the load everywhere.

What Open Session in View hides

Spring Boot registers OpenEntityManagerInViewInterceptor and leaves spring.jpa.open-in-view at true by default. That keeps the persistence context open for the whole HTTP request, past the service transaction and through view rendering or JSON serialization. Open Session in View is why a lazy load in your controller or in Jackson succeeds instead of throwing LazyInitializationException.

That sounds helpful, and it is the trap. With OSIV on, an N+1 in the serialization layer runs quietly: the queries fire during rendering, nothing throws, and your tests pass. Turn it off (spring.jpa.open-in-view=false) and the same unplanned lazy access fails fast, forcing you to fetch the right graph inside the service. Spring Boot even logs a startup warning nudging you to set the flag explicitly rather than lean on the default.

Wrong "Open Session in View is a performance feature, so leave it on."

Right "It is a convenience that hides fetch bugs. Prefer fetching the required graph in the transactional service and returning DTOs, then turn OSIV off so leaks surface."

[ senior depth ]

Flush timing, flush modes, and what readOnly actually does

Dirty checking only runs at a flush, so flush timing decides when your writes hit the database. Hibernate's default FlushMode.AUTO flushes before a query whose results could be affected by pending changes, and before commit. COMMIT flushes only at commit. MANUAL (and ALWAYS) exist for the cases where you want full control or force a flush every query.

The interview-relevant lever is @Transactional(readOnly = true). On a Hibernate-backed transaction it sets the session's flush mode to MANUAL, so a pure read does no dirty checking and no flush. Since Spring 5.1 it also propagates read-only to the session (setDefaultReadOnly(true)), so Hibernate discards the loaded-state snapshot it would otherwise keep for dirty checking, which saves real memory on large result sets. Spring's own docs note readOnly applies only to REQUIRED and REQUIRES_NEW propagation.

Wrong "readOnly makes the queries faster or fewer."

Right "readOnly changes flush and snapshot behavior, not the SQL. It stops accidental writes from a read path, skips the flush, and lets Hibernate drop the dirty-checking snapshot. Query count is unchanged."

Propagation (REQUIRED vs REQUIRES_NEW), rollback rules for checked vs unchecked exceptions, and why a @Transactional self-invocation is silently ignored are proxy and transaction-manager concerns owned by spring-core. The Hibernate-specific addition is that the persistence context is bound to that transaction: the flush, the first-level cache, and lazy loading all live and die with it.

The fetch-join traps: bags and pagination

Fetch joins fix N+1, but two Hibernate-specific failures wait for anyone who over-uses them.

Join-fetch two collections mapped as List on the same root and Hibernate throws MultipleBagFetchException. A List with no @OrderColumn is a bag (unordered), and Hibernate cannot map result rows back to the correct parent for two bags at once. Mapping one side as a Set avoids the exception, but you have only traded it for a Cartesian product: two joined collections multiply into rows(a) * rows(b). The clean rule is to fetch at most one collection per query and let the persistence context stitch the rest.

The second trap is pagination over a fetched collection:

// join fetch a collection + setMaxResults -> HHH000104
@Query("select o from Order o join fetch o.lineItems")
List<Order> firstPage(Pageable page);

Hibernate cannot apply LIMIT in SQL here, because truncating the joined result would cut a parent off mid-collection and hand you a half-loaded order. So it logs HHH000104, pulls the entire result set, and paginates in memory. On a large table that is an out-of-memory risk hiding behind a warning. Fixes: page the root ids in one query, then fetch collections for exactly those ids in a second; or set hibernate.query.fail_on_pagination_over_collection_fetch=true so the warning becomes an exception you cannot miss.

For the common case where you want lazy loading but not N+1, batch fetching is the low-friction option. @BatchSize(size = 25) on a collection or entity, or the global hibernate.default_batch_fetch_size, makes Hibernate load pending proxies in IN (?, ?, ...) batches instead of one-by-one, turning N+1 into N/25+1 without changing your query shape.

First-level vs second-level cache

The first-level cache is the persistence context itself, scoped to one session, and you do not opt out of it: it is what gives you object identity and dirty checking inside a transaction. The second-level cache is different in scope and lifetime. It is SessionFactory-scoped, shared across sessions and transactions, and off by default.

To use it you enable hibernate.cache.use_second_level_cache, plug in a provider, and mark entities cacheable with @Cache(usage = ...) or the JPA @Cacheable plus jakarta.persistence.sharedCache.mode. The concurrency strategy is the load-bearing choice: READ_ONLY for immutable reference data, NONSTRICT_READ_WRITE and READ_WRITE for mutable data with different consistency guarantees, TRANSACTIONAL for a JTA provider.

Wrong "The second-level cache also caches my query results, so repeated findByStatus calls skip the database."

Right "It caches entities by id. A query like findByStatus still runs its SQL to get the id list unless you separately enable the query cache, which caches only the ids the query returned and then hydrates each from the entity cache."

The classic footgun is enabling the query cache without the entity cache: you store id lists but reload every entity, sometimes adding an N+1 rather than removing one.

Spring Data projections and repository pitfalls

When an endpoint needs three columns, returning the full entity is wasted I/O and drags the whole lazy-loading question along with it. Spring Data projections let a query method return a narrower shape.

A closed projection is an interface whose getters all map to real properties. Spring Data knows every field the proxy needs up front, so it can restrict the selected columns:

interface OrderSummary {
    Long getId();
    String getStatus();
}
List<OrderSummary> findByCustomerId(Long id);

An open projection uses @Value with a SpEL expression. Because the expression can reference any property of the aggregate root, Spring Data cannot optimize the selection and loads the full entity, then computes the value. If you reached for a projection to cut I/O, an open one quietly defeats the purpose.

DTO (class-based) projections use a constructor expression. Spring Data rewrites select o from Order o returning a DTO into select new ...OrderDto(o.id, o.status) from Order o, so you get plain objects outside the persistence context, which sidesteps lazy loading and OSIV entirely for read endpoints.

Two repository habits worth naming in a review. Calling findById inside a loop is an N+1 written by hand; replace it with a single findAllById(ids). And saveAll is not a batch insert by itself: without hibernate.jdbc.batch_size set (and an id strategy that does not force a round trip per row, so IDENTITY blocks JDBC batching), Hibernate still issues one INSERT per entity.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

gapmap pro

You've read the Spring Data JPA and Hibernate notes. An interviewer will ask you to prove them.

Know you're ready. Don't hope.

The notes are free: every topic, senior depth. Pro is what turns reading into readiness you can prove:

  • Mock interviews on your topics

    An AI interviewer that pushes back with follow-ups and grades you honestly. Per module, fair-use unlimited.

  • Voice test interviews

    The dress rehearsal, out loud: questions from your map, a transcript, and a per-answer verdict.

  • A plan to your date

    Ready or not ready, per module, as the interview approaches. Recomposed whenever your goal changes.

  • A mentor inside every lesson

    Stuck on the senior notes? Ask, drill deeper, get re-quizzed on the spot.

Pro $20/mo · Pro+ $50/mo · every study note on the site stays free

builds on

more Java

was this useful?