Files
hibernate-demo/docs/11-proxies-and-lazy-initialization.md
T

13 KiB
Executable File

11 — Proxies and LazyInitializationException, verified against Hibernate 7.4.5.Final

← Previous: 10 — Mocking JNDI datasources | Next: 12 — Association mappings →

Backs ankurm.com: proxies and the LazyInitializationException.

Companion code: src/main/java/com/ankurm/hibernatedemo/proxy/ (entities — ProxyBook, ProxyPublisher, ProxyReview) and src/test/java/com/ankurm/hibernatedemo/proxy/ (six test classes, twelve tests, all green). Raw output: docs/output/proxy-*.txt. Environment: Hibernate ORM 7.4.5.Final, Spring Boot 4.1.1, Spring Framework 7.0.9, Jakarta Persistence 3.2.0, JDK 25 (Temurin), H2 2.4.240.

This chapter and chapter 01 are the same mechanism seen from two directions: 01 measures what get() vs getReference() actually do to the persistence context and when a SELECT fires; this chapter measures what the object getReference() hands back actually is, and what breaks when it outlives its session. Read together, they cover the whole proxy lifecycle from creation to LazyInitializationException.

What a proxy actually is, and the two ways to break naive code with it

getReference() does not hand you back an instance of your entity class. In 7.4.5.Final it hands you a ByteBuddy-generated nested class named $HibernateProxy — com.ankurm.hibernatedemo.proxy.ProxyBook$HibernateProxy, confirmed live in docs/output/proxy-lazy-init-and-identity.txt. That is not the Javassist-era naming scheme (EntityName_$$_javassist_N) that still circulates in older blog posts and even in some current ones — Hibernate moved its default bytecode provider to ByteBuddy years ago, and 7.4.5 keeps the proxy as a literal static nested class of the entity itself, not a same-package sibling class.

Two things follow from that, and both are demonstrated in ProxyIdentityTest:

  • proxy instanceof ProxyBook is true — the generated class extends your entity.
  • proxy.getClass() == ProxyBook.class is false. Any code that branches on getClass() instead of instanceof, or relies on the JPA-default equals()/hashCode() (object identity), silently breaks: a HashSet<ProxyPublisher> containing the real, managed instance does not recognize a proxy for the exact same row as a member (proxyIdentityTest .naiveEqualsAndHashSet_cannotRecognizeProxyAndRealInstanceAsTheSameRow, asserted, not claimed). Chapter 01's own proxy-identity experiment reaches the identical conclusion from the get()/getReference() side — see 01 — get() vs getReference(), Proxy identity experiment.

Hibernate.getClass(proxy) is the fix — it unwraps to the real entity class regardless of proxy state, and it's asserted to return ProxyPublisher.class even for an uninitialized reference.

The exception, verbatim — and it is not one message, it's two

Every blog post on this topic (including the one this replaces) quotes a single wording for LazyInitializationException. Running it for real turns up two different message templates, depending on whether the frozen thing is a to-one association or a collection:

# Collection (ProxyBook.reviews, a LAZY @OneToMany), accessed after session close:
Cannot lazily initialize collection of role 'com.ankurm.hibernatedemo.proxy.ProxyBook.reviews' with key '1' (no session)

# To-one proxy (a getReference() result), accessed after session close:
Could not initialize proxy [com.ankurm.hibernatedemo.proxy.ProxyBook#3] - no session

Note the capitalization on the second one too: it's Could (capital) and no session (lower-case s) — not the commonly-quoted "could not initialize proxy ... no Session". Both are org.hibernate.LazyInitializationException (verified: ex.getClass().getName() printed and asserted equal to that string), but if you're grepping logs for one exact phrase, you need both. See LazyInitializationTest for both reproductions and docs/output/proxy-lazy-init-and-identity.txt for the verbatim capture (also mirrored, unfiltered, in docs/output/proxy-lazy-and-identity-run.txt).

Hibernate.initialize() / isInitialized() / unproxy() — confirmed against the 7.4.5 jar

javap against hibernate-core-7.4.5.Final.jar (full output in docs/output/proxy-settings-javap.txt) shows the signatures actually shipped:

public static void initialize(java.lang.Object) throws org.hibernate.HibernateException;
public static boolean isInitialized(java.lang.Object);
public static java.lang.Object unproxy(java.lang.Object);
public static <T> T unproxy(T, java.lang.Class<T>);

unproxy() has two overloads, not one — a no-cast version that returns Object, and a typed version that takes the target Class<T> and returns T directly. Both are exercised in LazyInitializationTest.hibernateUnproxy_.... A side effect worth knowing: calling Hibernate.unproxy() on an uninitialized proxy initializes it — isInitialized() flips from false to true as a consequence, not just as a precondition.

fetchgraph vs. loadgraph, demonstrated, not just stated

Every article states the rule ("loadgraph keeps defaults for what's not named; fetchgraph forces everything not named to LAZY") and almost none show it happening. EntityGraphFetchTest builds one entity (ProxyBook) with two associations of different default fetch types — publisher (@ManyToOne, default EAGER) and reviews (@OneToMany, explicit LAZY) — and one named graph that mentions only reviews:

Hint SQL joins publisher after fetch reviews after fetch
none (plain find()) left join proxy_publisher only initialized (mapped EAGER) not initialized (mapped LAZY)
jakarta.persistence.loadgraph left join proxy_publisher and left join proxy_review initialized initialized
jakarta.persistence.fetchgraph left join proxy_review only not initialized (forced to LAZY, despite EAGER mapping) initialized

The middle column is the whole point: with fetchgraph, book.getPublisher() comes back as a ProxyPublisher$HibernateProxy even though the mapping says EAGER, because fetchgraph treats the graph as the entire fetch plan rather than an addition to the mapping's defaults. Verbatim SQL for all three shapes is in docs/output/proxy-entitygraph-fetch-vs-load.txt (raw run in docs/output/proxy-entitygraph-run.txt). Chapter 12 covers the collection-side counterpart of this same lazy/eager boundary — N+1 counting and @BatchSize — see 12 — Association mappings.

hibernate.enable_lazy_load_no_trans: still there, still works, now flagged @Unsafe

This setting has a reputation for being on its way out. It is not gone in 7.4.5.Final — org.hibernate.cfg.TransactionSettings.ENABLE_LAZY_LOAD_NO_TRANS still resolves to the string hibernate.enable_lazy_load_no_trans (confirmed by javap, see docs/output/proxy-settings-javap.txt). What is new-ish and worth a headline: the constant now carries a @org.hibernate.cfg.Unsafe marker annotation — an empty marker interface Hibernate's own source uses to flag settings it does not want you reaching for, without actually removing them. OpenInViewAndLazyLoadNoTransTest turns it on (spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true) and confirms it functionally does what it always did: a to-one proxy, accessed after its originating session and transaction have both closed, initializes successfully instead of throwing — Hibernate opens a temporary session behind the scenes to service exactly that one access. The @Unsafe label is Hibernate's own commentary on why you generally shouldn't reach for this, not a sign it's been deprecated or removed.

OSIV: the default, its warning, and what it actually masks

spring.jpa.open-in-view defaults to true when left unset, and Boot prints a warning at startup that says exactly that — verified verbatim (not paraphrased) against org.springframework.boot.jpa.autoconfigure.JpaBaseConfiguration$JpaWebConfiguration's bytecode:

spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed
during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning

(Note the package: org.springframework.boot.jpa.autoconfigure, not org.springframework.boot .autoconfigure.orm.jpa — Boot 4 split its autoconfigure module and JPA's auto-configuration now lives in the separate spring-boot-jpa artifact.)

Proving the masking claim required a real HTTP request — OSIV is implemented by OpenEntityManagerInViewInterceptor, which only exists for actual servlet requests, so OsivDefaultWarningTest and OsivDisabledExceptionTest spin up a real embedded Tomcat (@SpringBootTest(webEnvironment = RANDOM_PORT)) with one controller (OsivBookController/OsivBookService) that returns a ProxyBook entity directly and lets Jackson serialize getReviews() on the response-writing thread — deliberately not a DTO, because the DTO is the fix, and the point here is what happens without it. Same code, same data, only the property differs:

# open-in-view left unset (Boot default true): GET /osiv/books/1
200  {"title":"OSIV Default Book","publisher":{...},"id":1,"reviews":[{"comment":"Rendered fine","id":1}]}

# open-in-view=false: GET /osiv/books/2 (identical entity graph, identical controller)
500  {"timestamp":"...","status":500,"error":"Internal Server Error","path":"/osiv/books/2"}

Server-side log for the second case (captured via CapturedOutput, not inferred):

Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write
JSON: Cannot lazily initialize collection of role 'com.ankurm.hibernatedemo.proxy.ProxyBook.reviews'
with key '2' (no session)]

Worth calling out as its own gotcha: the HTTP error body Boot's default BasicErrorController returns is a bare {"timestamp":...,"status":500,...} even with server.error.include-message=always set — the failure happens inside the Jackson HttpMessageConverter while writing the response, which Boot reports as a generic 500 rather than routing the original exception's message into the error attributes map. If you only look at the HTTP response, you will not learn why it failed; you have to check the application log, which is exactly what real incident response looks like for this failure mode. Full sequence in docs/output/proxy-osiv-and-no-trans.txt (raw run in docs/output/proxy-osiv-run.txt).

Bytecode enhancement (hibernate-enhance-maven-plugin) — not run here

The article draft this replaces implies bytecode enhancement changes proxy behavior in ways worth demonstrating. Two settings for it are real and confirmed present in 7.4.5.Final — org.hibernate.cfg.BytecodeSettings.ENHANCER_ENABLE_LAZY_INITIALIZATION, ENHANCER_ENABLE_DIRTY_TRACKING, and ENHANCER_ENABLE_ASSOCIATION_MANAGEMENT all exist (docs/output/proxy-settings-javap.txt). Wiring up the actual Maven plugin to enhance classes at build time and diff the resulting behavior was not attempted: a quick check for org.hibernate.orm.tooling:hibernate-enhance-maven-plugin:7.4.5.Final and org.hibernate.orm:hibernate-enhance-maven-plugin:7.4.5.Final on Maven Central both came back "could not be resolved" in this sandbox. Per the brief's own guidance, this looked like a rabbit hole rather than a quick add — flagging it honestly rather than guessing at what enhancement would do differently here.

Testcontainers, Docker, and the honest gap

Not applicable to this chapter directly (see chapter 09 for that), but it's worth noting here too: everything above ran against a single shared H2 in-memory database in a sandboxed container. No Docker was available, so nothing here was cross-checked against a real Postgres/MySQL proxy-and-lazy-loading story — the LazyInitializationException and proxy identity mechanics are Hibernate-internal and database-agnostic, but that claim itself rests on understanding of Hibernate's architecture rather than an executed cross-database test.

← Previous: 10 — Mocking JNDI datasources | Next: 12 — Association mappings →