Files

12 KiB
Executable File

06 — Hibernate 7 Natural IDs — what L1 and L2 actually give you, measured

← Previous: 05 — JPA persistence annotations | Next: 07 — Immutable entities →

Backs ankurm.com: Hibernate 7 natural IDs.

Post 4865 makes several precise claims about what natural-id resolution buys you with and without a second-level cache. All of them were tested with Hibernate Statistics rather than taken on faith — see docs/output/naturalid-tests.txt for the verbatim counts and the test classes under src/test/java/com/ankurm/hibernatedemo/naturalid/ for exact setup.

The API surface: three methods, not two

javap on org.hibernate.Session in hibernate-core-7.4.5.Final.jar confirms all three natural-id entry points exist, each with Class and entity-name (String) overloads:

NaturalIdLoadAccess<T> byNaturalId(Class<T>)
SimpleNaturalIdLoadAccess<T> bySimpleNaturalId(Class<T>)
NaturalIdMultiLoadAccess<T> byMultipleNaturalId(Class<T>)

Post 4865 only ever demonstrates byNaturalId(...).using(...).load() and byMultipleNaturalId(...).multiLoad(...). It never mentions bySimpleNaturalId, which is the more ergonomic form for the single-field case the article's own Product/sku example uses — session.bySimpleNaturalId(Product.class).load("SKU-123") instead of .byNaturalId(Product.class).using("sku", "SKU-123").load(). Not wrong, just an incomplete API tour; NaturalIdL1CacheTest and NaturalIdL2CacheTest use bySimpleNaturalId throughout to close that gap, exercising NaturalIdProduct and CachedNaturalIdProduct respectively.

The central question: does L1 save a query, with NO L2 cache configured?

Yes — confirmed exactly as the article claims. NaturalIdL1CacheTest persists a row, then calls session.bySimpleNaturalId(NaturalIdProduct.class).load("SKU-L1-1") twice in the same session, with no @NaturalIdCache, no @Cache, no L2 provider configured anywhere:

  • 1st call: 1 query (getPrepareStatementCount() goes from 0 to 1)
  • 2nd call, same session: still 1 — zero additional queries, and the returned reference is isSameAs() the first

A second test in the same class proves the flip side the article also asserts: a new session repeating the identical lookup does re-fire the query (cumulative count goes from 1 to 2). So the L1 natural-id-to-PK map is real, it does save a query on repeat lookups, and it dies with the session exactly as documented.

Turning on L2: hibernate-jcache + ehcache

The article recommends "@NaturalIdCache is mandatory for cross-session performance" without showing numbers. To get real ones, this required going online (these two dependencies were not already warm in the sandbox's ~/.m2):

<dependency>
  <groupId>org.hibernate.orm</groupId>
  <artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
  <groupId>org.ehcache</groupId>
  <artifactId>ehcache</artifactId>
  <version>3.10.8</version>
  <classifier>jakarta</classifier>
  <exclusions>
    <exclusion>
      <groupId>org.glassfish.jaxb</groupId>
      <artifactId>jaxb-runtime</artifactId>
    </exclusion>
  </exclusions>
</dependency>

Two things worth flagging for anyone reproducing this: (1) hibernate-jcache correctly pulls javax.cache:cache-api — not a jakarta.cache artifact, because JSR-107 (JCache) was never migrated to the Jakarta namespace, unlike JPA/Bean Validation/etc.; (2) plain org.ehcache:ehcache resolves but drags in an ancient javax.xml.bind:jaxb-api transitively through jaxb-runtime that fails to resolve from Maven Central (a dead maven.java.net mirror) — the jakarta classifier plus excluding jaxb-runtime sidesteps it cleanly.

Settings used (hibernate.cache.region.factory_class=jcache, hibernate.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider) are exercised in NaturalIdL2CacheTest, booted as a standalone SessionFactory (not through the shared Spring context) so the L2 configuration doesn't leak into the other agents' tests. This is the same hibernate-jcache dependency that turns out to have a much bigger, repo-wide side effect than "enables the natural-id cache when you ask for it" — see chapter 09's writeup of JCacheOnClasspathAutoEnablesL2Test for why application.yml in this repo pins hibernate.cache.use_second_level_cache: false explicitly, everywhere, regardless of what any individual chapter wants.

The surprise: @NaturalIdCache populates on INSERT, not on first lookup

The natural mental model is "L2 cache fills on the first miss." That is only half true. bySimpleNaturalId_withNaturalIdCache_crossSession_savesTheQuery shows that immediately after persist() + commit() on a @NaturalIdCache-annotated entity, Statistics.getNaturalIdCachePutCount() is already 1 — before anyone has looked the entity up by its natural id at all. The consequence: both a same-process "first" lookup and a genuinely new-session lookup resolve with zero additional queries and register as cache hits (getNaturalIdCacheHitCount() = 2 across both). If your mental model of L2 warm-up is "miss once per cold value," that is wrong for entities inserted through Hibernate itself.

To see the miss-then-hit path the article's mental model actually describes, bySimpleNaturalId_rowInsertedOutsideHibernate_firstLookupIsARealMiss_secondIsARealHit inserts a row via raw JDBC (bypassing Hibernate's persist-time cache population entirely). That row produces a real recorded miss + put on the first bySimpleNaturalId call (1 query, 1 miss, 1 put), and a real hit with zero additional queries on the second, brand-new-session call. Both behaviors are real; the article only describes the second one, and most natural-id rows in a typical app are inserted through Hibernate, which means the first (surprising) path is actually the common case in practice, not the edge case.

Post 4865 recommends immutability as a best practice but doesn't say what enforcement exists. NaturalIdMutabilityTest shows Hibernate actively checks it: mutating a @NaturalId field left at its default (mutable = false, ImmutableNaturalIdEntity) and flushing throws

org.hibernate.HibernateException: An immutable natural identifier of entity
...ImmutableNaturalIdEntity was altered from `CODE-A` to `CODE-B`

This fires even with no @Column(updatable = false) guard on the column — it is Hibernate's own natural-id consistency check, not a side effect of a JPA column setting. @NaturalId(mutable = true) (MutableNaturalIdEntity), by contrast, flushes the change through cleanly (verified via a native query reading the updated column back), and a subsequent bySimpleNaturalId lookup by the old value correctly returns null while the new value resolves to the same row — no stale L1 entry survives the mutation within the same test's fresh sessions.

Composite natural ids: the article's exact code sample, verified

Department's two-field @NaturalId (company, deptCode) is lifted straight from post 4865's example. CompositeNaturalIdTest proves it actually works: two Department rows with the same deptCode ("ENG-01") at two different Company instances resolve independently via session.byNaturalId(Department.class).using("company", acme).using("deptCode", "ENG-01").load(), in exactly one query. The generated SQL, captured verbatim:

select d1_0.id,c1_0.id,c1_0.name,d1_0.dept_code,d1_0.name
from department d1_0
left join company c1_0 on c1_0.id=d1_0.company_id
where d1_0.company_id=? and d1_0.dept_code=?

Note it filters on company_id (the FK column), not by joining into Company's own natural id or PK independently — the composite key is resolved as a single WHERE clause, as expected.

The equals/hashCode advice holds up — with one nuance the article doesn't mention

Posts 4864/4865 recommend basing equals()/hashCode() on the natural id rather than the surrogate id, specifically to avoid the "hash code changes after persist()" trap. This was tested head-to-head against the surrogate-id version (see the sibling persistenceannotations package's IdBasedEqualsHashSetTrapTest, which reproduces the surrogate-id failure — see chapter 05): with natural-id-based equals()/hashCode() (NaturalIdEqualsEntity), an entity added to a HashSet before persist() is still found by contains() after persist() — the advice is correct, because the natural id (and hence the hash code) is set at construction time and never changes.

The nuance: two distinct transient objects that happen to share the same natural id value are already equals() to each other, before either is persisted. NaturalIdEqualsHashSetTest shows a HashSet.add() on a second transient instance with the same sku but a different name is silently rejected as a duplicate. That is usually the intended behavior (natural ids are supposed to be unique business keys), but it means natural-id-based equality can mask a bug where two meant-to-be-different objects were accidentally constructed with the same business key before either ever reached the database — a failure mode the surrogate-id approach cannot produce (two transient surrogate-id objects are never accidentally "the same" via equals() unless you specifically wrote id-based equality, which the article correctly warns against for other reasons).

Numbers summary

Scenario Queries Cache hits/misses Source
No L2, bySimpleNaturalId x2, same session 1, then 1 (0 new) n/a naturalid-tests.txt
No L2, bySimpleNaturalId, new session 1, then 2 cumulative (+1) n/a naturalid-tests.txt
@NaturalIdCache, insert then 2 lookups (own inserts) 0 additional after insert 2 hits, 0 miss naturalid-tests.txt
@NaturalIdCache, row inserted outside Hibernate 1 (miss), then 1 cumulative (0 new) 1 miss+put, then 1 hit naturalid-tests.txt
Composite natural id lookup 1 n/a naturalid-tests.txt

All counts measured via SessionFactory.getStatistics() in this sandbox; treat absolute timings as indicative only, but the query counts are exact and reproducible (deterministic, not timing-based).

The three natural-id entry points on Session, read straight off the jar, are in docs/output/naturalid-javap-session.txt.

← Previous: 05 — JPA persistence annotations | Next: 07 — Immutable entities →