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

7.9 KiB
Executable File
Raw Blame History

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. @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 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 →