Add Hibernate 7 batches 2-6, batch 7, and batch 8: mapping styles, JPA annotations, natural IDs, @Immutable, stored procedures, in-memory test databases, JNDI mocking, proxies, associations, temporal mapping, named queries, HQL, Criteria API, EntityManager bootstrapping, Ehcache 3 L2 cache configuration, HikariCP connection pooling, Hibernate Validator CDI integration, aggregate functions, sorting, pagination, interceptors, and Hibernate Search 8 (Hibernate 7.4.5.Final + Spring Boot 4.1.1 + JDK 25)

This commit is contained in:
2026-09-20 06:06:42 +00:00
committed by Claude
commit 8568c0ce6c
330 changed files with 23668 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
# 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)