Files
hibernate-demo/docs/07-immutable-entities.md
T

131 lines
7.9 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)