# 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: [. with owner 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)