7.9 KiB
Executable File
07 — @Immutable entities in Hibernate 7 (post 4866)
← Previous: 06 — Natural IDs | Next: 08 — Stored procedures →
Backs ankurm.com: Hibernate 7 immutable entities. Verified on Hibernate ORM 7.4.5.Final, jakarta.persistence-api 3.2.0, H2 2.4.240, JDK 25 (Temurin 25.0.4.1).
Test classes: ImmutableEntityTest,
ImmutableBulkUpdateAllowedTest,
ImmutableFlushCostTest.
Entities: immutable/.
./mvnw -Dtest=ImmutableEntityTest,ImmutableBulkUpdateAllowedTest,ImmutableFlushCostTest test
Raw captured output:
immutable-headline-and-boundaries.txt,
immutable-javap-annotation.txt.
What @Immutable actually is, per the class file
javap -v org.hibernate.annotations.Immutable (Hibernate ORM 7.4.5.Final) shows it targets
TYPE, METHOD, FIELD and carries zero annotation members — no value(), nothing to
configure. It's a pure marker. That matches how the source articles use it, but is worth stating
precisely: there is no per-field opt-out, no "immutable except this column" mode. Immutability at
the entity level is all-or-nothing; the granularity you get is choosing which fields or which
collection to put the annotation on, not tuning behaviour within one.
The headline behaviour: silence, not an exception
Mutate a managed @Immutable entity's field and flush inside a transaction. Nothing happens —
literally nothing observable. No UPDATE is sent (Statistics.getEntityUpdateCount() stays at
0), and commit() does not throw. The row you reload afterwards is untouched. This is the part
worth building intuition around: @Immutable is not a guard that rejects writes, it's a filter
that makes Hibernate blind to them. If you were expecting a StaleStateException or a validation
failure when someone accidentally mutates one of these entities, you will not get one — you get
quiet data loss of the in-memory change, and the database keeps whatever it already had.
The three things @Immutable does NOT stop
Verified independently, each behaving differently:
-
EntityManager.remove()/ DELETE — goes through normally.@Immutableonly removes the entity from dirty-checking; it says nothing about the persister's ability to issue a DELETE when you explicitly ask for one. -
Native SQL — always works, unconditionally. Native SQL never goes through Hibernate's entity-state machinery at all, so there is no layer for
@Immutableto intercept. -
Bulk HQL
update ... set ...— this is the one correction to make explicitly, because the intuitive answer is wrong. It is tempting to assume bulk HQL bypasses@Immutablethe same way native SQL does (both skip per-entity dirty checking). It does not: Hibernate 7.4.5 refuses to even translate the query, at HQL-compile time, before touching the database:org.hibernate.query.sqm.InterpretationException: Error interpreting query [The query attempts to update an immutable entity: [exchange_rate] (set 'hibernate.query.immutable_entity_update_query_handling_mode' to suppress)]The property named in the message,
org.hibernate.cfg.QuerySettings.IMMUTABLE_ENTITY_UPDATE_QUERY_HANDLING_MODE(hibernate.query.immutable_entity_update_query_handling_mode), is aSessionFactory-wide three-way enum:EXCEPTION(default),WARNING,ALLOW. Set toALLOW, the bulk update actually executes against the immutable table — verified inImmutableBulkUpdateAllowedTest, a separate Spring context with the property set, since it is not a per-query hint. Bulk DELETE via HQL, by contrast, is not blocked at all — only bulk UPDATE has this guard.
@Immutable on a collection
Putting @Immutable on a @OneToMany is a separate annotation usage from putting it on the
owning entity — you can have a mutable parent with an immutable child collection (that's what
RateWithAuditTrail demonstrates). Adding an element to that collection and flushing throws, and
the exact shape matters if you're writing a catch clause: the immediate exception is
jakarta.persistence.RollbackException wrapping the transaction commit, and its root cause is a
plain org.hibernate.HibernateException (not a dedicated subclass) with the message:
Immutable collection was modified: [<Entity>.<collection> with owner id '<id>']
Catch HibernateException (or inspect the cause chain), not a more specific type — there isn't
one.
@Immutable + @Version
Hibernate 7.4.5 accepts the combination without a startup error. It is exactly as inert as the
source article warns: the version column is written once at INSERT (starting at 0) and never
increments afterward, because there is no UPDATE for it to ride along on. This isn't a distinct
code path from the headline case — it's the same "flush sees a null snapshot, skips the entity
entirely" mechanism, applied to an entity that happens to also carry a @Version field.
@Immutable vs Session.setReadOnly() / setDefaultReadOnly()
Both Session.setReadOnly(entity, true) (per-instance) and Session.setDefaultReadOnly(true)
(session-wide default for everything loaded after the call) produce the same observable
outcome as @Immutable on a mutated-and-flushed entity: zero UPDATEs, no exception. The
difference is entirely about when the decision is made and how durable it is:
@Immutable |
setReadOnly() / setDefaultReadOnly() |
|
|---|---|---|
| Scope | Class-level, every instance, every session | Per entity instance, or per session |
| Decided | At mapping time (compile time) | At runtime, per Session |
| Reversible | No (would need a redeploy) | Yes, per instance or per session |
| Cost paid | Never allocates a snapshot at all | Still allocates the snapshot; the read-only flag is checked at flush instead |
That cost line is the one worth measuring rather than asserting. ImmutableFlushCostTest loads
4,000 rows of a 12-column entity (both @Immutable and plain, no pending changes) into a fresh
persistence context and times a single flush() around the load. On this sandbox (a shared
container — treat as indicative of direction and rough magnitude, not a citable number), flushing
the mutable set took ~8.5ms; the @Immutable set took ~1.9–2.5ms across two runs — a
3.4×–4.5× difference, purely from Hibernate having a snapshot to compare 12 fields against on
one side and nothing to check at all on the other. setReadOnly()/setDefaultReadOnly() sit
architecturally on the "still allocates a snapshot" side of that line — they suppress the
write, not the snapshot allocation and comparison @Immutable skips outright. Confirming that
distinction with a clean timing delta would need a dedicated benchmark isolating snapshot
allocation specifically; this test measures the flush-time symptom, not the allocation itself, so
say that plainly rather than overclaiming a mechanism from a flush timing.
Practical takeaway
@Immutable is a mapping-time, all-instances, unconditional decision. Reach for it for data that
is architecturally never going to change (reference data, audit rows, historical snapshots).
Reach for Session.setReadOnly() instead when the read-only-ness is a per-request or
per-session decision — a reporting query that happens to load entities it has no business
writing back, for instance — where you want the same flush suppression without committing the
entity class itself to being permanently immutable.
← Previous: 06 — Natural IDs | Next: 08 — Stored procedures →