131 lines
7.9 KiB
Markdown
Executable File
131 lines
7.9 KiB
Markdown
Executable File
# 07 — `@Immutable` entities in Hibernate 7 (post 4866)
|
||
|
||
[← Previous: 06 — Natural IDs](06-natural-ids.md) | [Next: 08 — Stored procedures →](08-stored-procedures.md)
|
||
|
||
Backs [ankurm.com: Hibernate 7 immutable entities](https://ankurm.com/mastering-hibernate-7-immutable-entities-performance-safety-and-best-practices/).
|
||
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`](../src/test/java/com/ankurm/hibernatedemo/ImmutableEntityTest.java),
|
||
[`ImmutableBulkUpdateAllowedTest`](../src/test/java/com/ankurm/hibernatedemo/ImmutableBulkUpdateAllowedTest.java),
|
||
[`ImmutableFlushCostTest`](../src/test/java/com/ankurm/hibernatedemo/ImmutableFlushCostTest.java).
|
||
Entities: [`immutable/`](../src/main/java/com/ankurm/hibernatedemo/immutable/).
|
||
|
||
```bash
|
||
./mvnw -Dtest=ImmutableEntityTest,ImmutableBulkUpdateAllowedTest,ImmutableFlushCostTest test
|
||
```
|
||
|
||
Raw captured output:
|
||
[`immutable-headline-and-boundaries.txt`](output/immutable-headline-and-boundaries.txt),
|
||
[`immutable-javap-annotation.txt`](output/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. `@Immutable` only 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 `@Immutable` to 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 `@Immutable` the 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 a `SessionFactory`-wide
|
||
three-way enum: `EXCEPTION` (default), `WARNING`, `ALLOW`. Set to `ALLOW`, the bulk update
|
||
actually executes against the immutable table — verified in
|
||
`ImmutableBulkUpdateAllowedTest`, 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 `UPDATE`s, 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](06-natural-ids.md) | [Next: 08 — Stored procedures →](08-stored-procedures.md)
|