StatelessSession, that does no tracking at all. Jakarta Persistence 4 takes that idea into the standard under a new name: EntityAgent. Hibernate 8 is the first Hibernate to implement it. This article builds a small project against the real jars, shows the SQL each call produces, and says plainly where the current pre-release builds still fall over.
Versions tested. Hibernate ORM 8.0.0.CR1 — the first release candidate, on Maven Central since 12 November 2026 (the Hibernate downloads page still listed Beta4 when I checked, so hibernate-core on Maven Central is the better source). Jakarta Persistence API 4.0.0-M8 — still a milestone; the Jakarta Persistence 4.0 specification page says final is targeted for late 2026. JDK Temurin 25.0.5+8, H2 2.5.252, HSQLDB 2.7.4 (comparison runs only), JUnit 6.1.3, Maven 3.9.11.Every code block links to the file it came from in the companion repository, and every console block is copied from a file in its
The Hibernate ORM 8.0 release page warns that new 8.0 features may still change in incompatible ways before the stable release. Every behaviour below is pinned by an assertion in the companion repository, so a later build that changes it fails the build instead of quietly making this article wrong.
output/ folder — written by a test that asserts the same numbers.
Why an edit made through an EntityAgent silently does nothing
With a normalEntityManager you rarely call an update method. You load an object, change a field, commit, and Hibernate writes the UPDATE. That works because the EntityManager keeps a persistence context: a memory of every object it loaded, plus a snapshot of what each one looked like at load time. At commit it compares object and snapshot and writes the differences. (This comparison is called dirty checking.)
An EntityAgent has no such memory. It is a thin layer over SQL: get is a SELECT, update is an UPDATE, and nothing happens that you did not ask for. That one difference explains everything else in this article.
EntityManager and then through an EntityAgent. Both are in Demo01Test.java.
lab.sf.runInTransaction(em -> {
Book b = em.find(Book.class, "978-1");
b.title = "Changed via EntityManager";
});
lab.sf.runInTransaction(EntityAgent.class, a -> {
Book b = a.get(Book.class, "978-2");
b.title = "Changed via EntityAgent";
});
The test records every SQL statement Hibernate prepares and writes it to output/01-entitymanager-vs-entityagent.txt. Read the statement counts first, then the last line of each block:
EntityManager: find, edit title, commit (2 statements)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
select version from final table (update book set price=?,publisher_id=?,title=?,version=?,views=? where isbn=? and version=?)
title in database afterwards: Changed via EntityManager
EntityAgent: get, edit title, commit (1 statement)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
title in database afterwards: Book 2
EntityAgent: get, edit title, update(book), commit (2 statements)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
select version from final table (update book set version=case when price is distinct from ? or publisher_id is distinct from ? or title is distinct from ? then ? else version end,price=?,publisher_id=?,title=?,views=? where isbn=? and version=?)
title in database afterwards: Changed via EntityAgent + update()
The EntityManager run issued a SELECT and then an UPDATE that nobody asked for. The EntityAgent run issued only the SELECT, and a fresh read afterwards still shows Book 2: the edit vanished. Adding one explicit call fixes it (the third case in Demo01Test.java):
lab.sf.runInTransaction(EntityAgent.class, a -> {
Book b = a.get(Book.class, "978-2");
b.title = "Changed via EntityAgent + update()";
a.update(b);
});
There is no symptom to catch. Nothing throws and nothing is logged. The only sign is a row that never changed. If you move code from anEntityManagerto anEntityAgentfor speed, every “load, modify, commit” path has to gain an explicitupdate().
Intermediate: why the two UPDATE statements look different
The SQL above is H2-specific: Hibernate wraps the update in select version from final table (update ...) so the new version comes back in the same round trip. Compare the two statements in output/01-entitymanager-vs-entityagent.txt and you will see the EntityAgent update writes the version through a case when ... is distinct from ? expression, while the EntityManager update sets it unconditionally. I did not investigate why the two paths differ, so treat that as an observation, not an explanation.
What matters for your code is the contract: on the EntityManager path the UPDATE only runs for objects dirty checking flagged; on the EntityAgent path it runs for whatever you pass to update(), whether or not anything changed.
Going deeper
- Demo01Test.java — the full test behind the transcript
- the JPA 4 M1 announcement — the Hibernate team’s own explanation of persistence-context operations versus database operations
- Hibernate 8.0 What’s New — where
EntityAgentis introduced
EntityAgent is Hibernate’s StatelessSession with a standard name
If you already knowStatelessSession, you already know most of EntityAgent. The 8.0 release notes describe EntityAgent as the standardised form of Hibernate’s StatelessSession. I checked that directly rather than trusting the sentence, by reading both interfaces from the jars with javap.
get, find, queries, statements) in a new parent interface, EntityHandler. EntityManager and EntityAgent both extend it, and Hibernate’s Session and StatelessSession extend them in turn. So existing StatelessSession code is already EntityAgent code.
The practical differences are in the verbs each side adds on top of EntityHandler:
| EntityManager | EntityAgent | |
|---|---|---|
| Remembers loaded objects | yes (persistence context) | no |
| Save a new row | persist | insert |
| Change a row | edit the object; flush | update (explicit) |
| Remove a row | remove | delete |
| Insert or update | merge | upsert |
| Load by id | find, get | find, get |
| Load a lazy association | on first access | fetch, on request |
SessionFactory on in-memory H2 (see Lab.java and pom.xml).
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>${hibernate.version}</version>
</dependency>
this.sf = new HibernatePersistenceConfiguration("lab")
.managedClasses(Book.class, Publisher.class, Setting.class)
.jdbcUrl(db.url())
.jdbcCredentials("sa", "")
.property("jakarta.persistence.schema-generation.database.action", "create")
.property("hibernate.jdbc.batch_size", String.valueOf(jdbcBatchSize))
.collectStatistics(true)
.statementInspector(s -> {
sql.add(s);
return s;
})
.createEntityManagerFactory();
Asking that factory for an agent and printing what you get back is the quickest way to confirm the relationship (Demo08Test.java):
createEntityAgent() returns: org.hibernate.internal.StatelessSessionImpl
instanceof org.hibernate.StatelessSession: true
You are depending on a milestone spec. Hibernate 8.0.0.CR1 resolvedjakarta.persistence-api4.0.0-M8 transitively. A release candidate sitting on a milestone API is fine for learning and for testing your own code, but the method names inEntityAgentcould still move before the spec is final.
Intermediate: what the 8.0 migration guide says changed for StatelessSession (from the docs)
This list comes from the Hibernate 8.0 migration guide and I have not run every item. Where I did verify something, I say so.
insert()returnsvoidonEntityAgent; Hibernate’s oldStatelessSessionreturned the generated identifier.javapconfirmsinsert(Object)isvoidon CR1. The generated id is now read from the entity after the call (shown in output/04-bulk-insert.txt).fetch()returns the fetched value instead ofvoid. Confirmed withjavap:<T> T fetch(T).StatelessSessionnow throws the exception types the JPA specification mandates, and a unique-key violation on insert is anEntityExistsException. I ran the duplicate-key case: see output/08-upsert-delete-and-errors.txt.- Stateless updates and deletes reject a detached entity whose tenant identifier is missing or wrong, before any SQL runs (not run here).
Session.replicate()andReplicationModewere removed; the guide points atupsert()for some import workflows (not run here).- Bean Validation:
pre-persistvalidation is now empty by default andpre-insertis the event to use, especially with a stateless session (not run here).
Reference: option types on EntityAgent (from the docs, not run)
The factory has createEntityAgent(EntityAgent.CreationOption...) and the agent has addOption(EntityAgent.Option) (both confirmed with javap; EntityAgent.Option extends CreationOption). The What’s New page lists Hibernate types that can be passed this way, such as TenantId, ReadOnlyMode, FlushMode and CacheMode. I did not exercise any of them.
Going deeper
- Hibernate 8.0 migration guide — the full list of behaviour changes
- Jakarta Persistence 4.0 specification page — status and feature list
- Multi-Tenancy with Hibernate 7 and Spring Boot 4 — background for the tenant checks mentioned above
get() throws, find() returns null, and there is no cache to save you
EntityHandler has both get and find, and they differ only when the row is missing. find returns null. get throws EntityNotFoundException. Both calls come from Demo02Test.java.
Book missing = a.find(Book.class, "no-such-isbn");
EntityNotFoundException e = assertThrows(EntityNotFoundException.class,
() -> a.get(Book.class, "no-such-isbn"));
The second difference matters more. Ask an EntityManager for the same id twice and the second answer comes from memory. An agent has nothing in memory, so it asks the database twice and hands back two different objects:
Book first = a.get(Book.class, "978-1");
Book second = a.get(Book.class, "978-1");
The transcript (output/02-get-find-and-no-cache.txt) puts the two handlers side by side:
agent.find(missing) -> null
agent.get(missing) -> EntityNotFoundException
EntityAgent, same id twice in one transaction:
same instance? false
SQL (2 statements)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
EntityManager, same id twice in one transaction:
same instance? true
SQL (1 statement)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
agent.getMultiple(3 ids) returned 3 books
SQL (1 statement)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn in (?,?,?)
Loading several ids together is one call, also from Demo02Test.java:
List<Book> books = lab.sf.callInTransaction(EntityAgent.class,
a -> a.getMultiple(Book.class, List.of("978-1", "978-2", "978-3")));
Repeated loads are real queries. Code that quietly relied on the persistence context to avoid repeatSELECTs will hit the database every time under an agent. Load once and keep the object, or load several ids together withgetMultiple, which produced a singleselect ... where isbn in (?,?,?)above.
Intermediate: how a lock gets requested on get()
get(Class, Object, FindOption...) takes find options, and LockModeType implements FindOption (both read with javap). That is how a lock is requested in the same call as the load; the lock-modes section below uses it.
Going deeper
- Demo02Test.java — the full test
- the JPA 4 M1 announcement — describes
getandgetMultipleand the detached result
Lazy associations stay lazy until you call fetch()
ABook points at a Publisher, and a Publisher owns a list of books. Both links are lazy. On an EntityManager the first access loads them for you. An agent does not: you ask for them explicitly with fetch(). The test is Demo03Test.java.
Book b = a.get(Book.class, "978-1");
boolean init = Hibernate.isInitialized(b.publisher);
After that get, the publisher is an uninitialised proxy. Loading it is one call, and the same works for the collection:
Publisher p = a.fetch(b.publisher);
var books = a.fetch(p.books);
agent.get(Book): publisher initialized? false
book.publisher.getName() before fetch -> LazyInitializationException
agent.fetch(book.publisher) -> getName() = Manning
same object as book.publisher? true, initialized now? true
reading the public field p.name instead -> null
SQL (1 statement)
select p1_0.id,p1_0.name from publisher p1_0 where p1_0.id=?
agent.get(Publisher): books collection initialized? false
agent.fetch(publisher.books) -> 3 books
SQL (1 statement)
select b1_0.publisher_id,b1_0.isbn,b1_0.price,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.publisher_id=?
The transcript is output/03-lazy-associations-and-fetch.txt. Reading getName() on the proxy before fetch() threw LazyInitializationException even though the agent was still open and inside its transaction. After fetch() the very same object (same object as book.publisher? true) is initialised and each fetch cost exactly one SELECT.
A public field on a proxy reads as null. My first draft of this test readp.nameinstead ofp.getName()and printednullright after a successfulfetch(). A Hibernate proxy is a generated subclass; reading a public field on it reads the subclass’s own empty field instead of calling through to the real object. This is ordinary Hibernate proxy behaviour, not something new in 8, but it is easy to hit when you are juggling objects that came fromfetch(). Use getters.
Going deeper
- Demo03Test.java — the full test, including the deliberate public-field read
- I did not test cascading (
persist-style cascades on an agent) at all; check before assuming it works either way.
Bulk inserts: where a stateless handler earns its keep
The reasonStatelessSession exists is volume. Every object an EntityManager persists stays in the persistence context until it is cleared or closed, so a loop that inserts thousands of rows holds thousands of objects. An agent holds none.
persist calls the EntityManager was tracking all 5,000, while the agent tracked nothing. The second group is a surprise, and it is why the diagram has three bars: calling insert() row by row prepared 5,000 statements, which looks like no JDBC batching at all (I inferred that from the prepare count and did not measure round trips), while insertMultiple() prepared one.
The loops are in Demo04Test.java. First the row-by-row version, then the list version:
lab.sf.runInTransaction(EntityAgent.class, a -> {
for (int i = 0; i < N; i++) {
a.insert(new Book("ag-" + i, "Title " + i, "9.99"));
}
});
lab.sf.runInTransaction(EntityAgent.class, a -> a.insertMultiple(batch));
EntityManager.persist x 5000 in one transaction
entities held in the persistence context at the end: 5000
entity inserts: 5000 statements prepared: 1
EntityAgent.insert x 5000 in one transaction
entities held anywhere: 0 (a StatelessSession has no persistence context)
entity inserts: 5000 statements prepared: 5000
EntityAgent.insertMultiple(list of 5000)
entity inserts: 5000 statements prepared: 1
publisher.id before insert: null
publisher.id after insert: 1 (insert() itself returns void)
That is output/04-bulk-insert.txt. The last two lines also show the other insert() change from the migration guide: it returns nothing, and a generated id lands on the object instead (publisher.id was null before and 1 after).
Read the number for what it is. The measured figure is statements prepared from Hibernate’s statistics, on an in-memory H2 database, withhibernate.jdbc.batch_size=50. It is not round trips and not elapsed time; I did not time anything. TheEntityManagerrow is the naive loop with noflush()/clear(), which is exactly the situation people move to a stateless session to escape.
Intermediate: how the numbers were collected
The persistence-context size is em.unwrap(Session.class).getStatistics().getEntityCount(). Statements prepared is sessionFactory.getStatistics().getPrepareStatementCount() with statistics switched on in the bootstrap. A StatementInspector in Lab.java also records the SQL text of every statement, which is where the SQL in the other transcripts comes from.
Going deeper
- Demo04Test.java — the full test
- Spring Data JPA Projections — another way to avoid loading more than you need
Lock modes: asking for OPTIMISTIC does more work than you expect
The 8.0 release notes sayStatelessSession and EntityAgent now accept every LockModeType when loading an entity or running a query, that optimistic modes get their protection immediately instead of deferring the version check to commit, and that both *_FORCE_INCREMENT modes bump the version immediately. I wanted to see what “immediately” means in SQL, so the test opens a transaction by hand to look at what has run after get() and again after commit() (Demo05Test.java):
try (EntityAgent a = lab.sf.createEntityAgent()) {
var tx = a.getTransaction();
tx.begin();
try {
a.get(Book.class, "978-1", mode);
get(), and commit() has nothing left to do. The top lane is the surprising one. A plain OPTIMISTIC read ended with for update, a row lock.
=== H2 ===
get(Book.class, "978-1", OPTIMISTIC)
right after get() (1 statement)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=? for update
during commit (0 statements)
version before: 0, after: 0
get(Book.class, "978-1", OPTIMISTIC_FORCE_INCREMENT)
right after get() (2 statements)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=? for update
update book set version=? where isbn=? and version=?
during commit (0 statements)
version before: 0, after: 1
That is the H2 half of output/05-lock-modes-in-entityagent.txt. The version column confirms the force-increment: 0 before and 1 after, with no update() call from my code.
Then the same test on HSQLDB:
=== HSQLDB ===
get(Book.class, "978-1", OPTIMISTIC)
FAILED: org.hibernate.HibernateException: Stateless optimistic locking requires transaction-long row protection; no strategy established for HSQLDialect:2:LOCKING
get(Book.class, "978-1", OPTIMISTIC_FORCE_INCREMENT)
FAILED: java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "owner" is null
get(Book.class, "978-1", PESSIMISTIC_WRITE)
right after get() (1 statement)
select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=? for update
during commit (0 statements)
version before: 0, after: 0
get(Book.class, "978-1", PESSIMISTIC_FORCE_INCREMENT)
FAILED: java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "owner" is null
Database support is not uniform yet. On HSQLDB,OPTIMISTICfailed with “Stateless optimistic locking requires transaction-long row protection; no strategy established for HSQLDialect:2:LOCKING”, which explains the H2 result: the database has to hold a row lock until commit for this mode to work. Both*_FORCE_INCREMENTmodes on HSQLDB threw aNullPointerExceptioninside Hibernate.PESSIMISTIC_WRITEworked. I have not reported theNullPointerExceptionupstream; search the Hibernate issue tracker before relying on it.
Intermediate: what the OPTIMISTIC row lock means for you
In my runs an optimistic stateless read behaves like a pessimistic one at the SQL level, so it can block other writers and can deadlock like one. If you chose OPTIMISTIC to avoid locks, an EntityAgent may not give you that on every database. Test on your target database, not only on H2.
Going deeper
- Demo05Test.java — the full test, including the HSQLDB failures
- Hibernate 8.0 What’s New — the lock-mode paragraph this section checks
Updating a counter without invalidating everyone else’s version
A@Version column protects a row from lost updates, but some columns change constantly and harmlessly, such as a page-view counter. JPA 4 adds @ExcludedFromVersioning for those. The migration guide says an update() that changed only such attributes leaves the version alone. The entity is Book.java:
@Version
public int version;
/** Changing only this field must not bump {@link #version}. */
@ExcludedFromVersioning
public int views;
loaded: version=0 views=0
after update() that changed only views: version=0 views=42
after update() that changed title: version=1 views=42
The transcript is output/06-excluded-from-versioning.txt: changing only views kept version=0, while changing title moved it to 1.
One case I did not test. I ran “only the excluded field changed” and “only a normal field changed”. What happens when one update() changes both is not covered by this repository.
Going deeper
- Demo06Test.java — the full test
- Hibernate 8.0 migration guide — the StatelessSession section describing this rule
Native queries without @SqlResultSetMapping
Native SQL results used to need an annotation on some entity. JPA 4 adds a programmatic API injakarta.persistence.sql: build a ResultSetMapping in code and pass it to createNativeQuery. I listed the classes with javap and then ran three kinds: an entity, a record through its constructor, and a tuple (Demo07Test.java).
The SQL renames a column on purpose so you can see the mapping doing its job:
String sql = "select isbn, title as book_title, price, version, views, publisher_id from book order by isbn";
ResultSetMapping<Book> asEntity = entity(Book.class,
field(Book.class, String.class, "isbn", "isbn"),
field(Book.class, String.class, "title", "book_title"),
field(Book.class, BigDecimal.class, "price", "price"),
field(Book.class, Integer.class, "version", "version"),
field(Book.class, Integer.class, "views", "views"));
List<BookSummary> summaries = lab.sf.callInTransaction(EntityAgent.class,
a -> a.createNativeQuery("select title, price from book order by isbn",
constructor(BookSummary.class,
column("title", String.class),
column("price", BigDecimal.class))).getResultList());
entity mapping through EntityAgent: 3 rows, first = 978-1 / Book 1 / 10.00
entity mapping through EntityManager: 3 rows, managed by the persistence context? true
constructor mapping -> BookSummary[title=Book 1, price=10.00]
tuple mapping -> (978-1, 0)
The transcript is output/07-programmatic-result-set-mapping.txt. The same entity mapping works on both handlers; the difference is what you get back. Through the EntityManager the rows are managed by the persistence context (em.contains was true); through the agent they are plain detached objects.
The entity mapping still wants the foreign key. My first attempt selected only the five columns I mapped and failed withColumn "PUBLISHER_ID" not found.Bookhas a lazy@ManyToOne, and Hibernate read the foreign key column even though I mapped no association. Addingpublisher_idto theSELECTfixed it. I did not try mapping the association itself.
Reference: the mapping building blocks (read with javap)
Static factories on ResultSetMapping: column, constructor, compound, tuple, entity, embedded and field. field also has an overload taking a SingularAttribute from the static metamodel, which is the type-safe route the JPA 4 M1 announcement describes. EntityMapping has a withLockMode method. I only ran entity, constructor, column, field and tuple.
Going deeper
- Demo07Test.java — the full test
- the JPA 4 M1 announcement — shows the mapping API with the static metamodel
upsert(), delete() and what a duplicate key looks like
upsert() inserts a row or updates it if the key already exists. On an entity with no version column it worked as hoped on H2: one SQL statement, a merge into, for each call (Demo08Test.java).
a.upsert(new Setting("theme", "dark")); // row exists
a.upsert(new Setting("language", "en")); // row does not exist
createEntityAgent() returns: org.hibernate.internal.StatelessSessionImpl
instanceof org.hibernate.StatelessSession: true
upsert(theme=dark) on an existing row, upsert(language=en) on a new row (2 statements)
merge into setting as t using (select cast(? as varchar(255)) name,cast(? as varchar(255)) val) as s on (t.name=s.name) when not matched then insert (name,val) values (s.name,s.val) when matched then update set val=s.val
merge into setting as t using (select cast(? as varchar(255)) name,cast(? as varchar(255)) val) as s on (t.name=s.name) when not matched then insert (name,val) values (s.name,s.val) when matched then update set val=s.val
theme -> dark
language -> en
insert() with an existing primary key -> jakarta.persistence.EntityExistsException
find() after delete() -> null
That is the whole of output/08-upsert-delete-and-errors.txt. The two upsert calls produced the same merge into statement, the existing row was updated (dark), the new row was inserted (en), a duplicate primary key on insert() raised jakarta.persistence.EntityExistsException, and find() after delete() returned null.
upsert() on a versioned entity did not work. TheSettingentity above has no@Version. When I calledupsert()onBook, which has one, it failed on both databases I tried. The next section has the exact exceptions.
Going deeper
- Demo08Test.java — the full test
- Hibernate 8.0 migration guide — notes on concurrent
upsert()handling and the unique-key exception
The same operations on H2 and HSQLDB do not behave the same
The cleanest example of a pre-release rough edge is a stale update. Two handlers read the same row, one writes, and the other then tries to write its old copy. That must fail with an optimistic-lock error. I ran it on both databases, next to the same conflict through theEntityManager (Demo09Test.java):
String agentStale = attempt(() ->
lab.sf.runInTransaction(EntityAgent.class, a -> a.update(stale)));
=== H2 ===
EntityAgent.update(stale book) -> org.hibernate.HibernateException: The database returned no natively generated values : com.ankurm.agent.Book
EntityManager.merge(stale book) -> jakarta.persistence.OptimisticLockException
EntityAgent.update(row not in db) -> org.hibernate.HibernateException: The database returned no natively generated values : com.ankurm.agent.Book
EntityAgent.upsert(new versioned) -> org.hibernate.HibernateException: The database returned no natively generated values : com.ankurm.agent.Book
=== HSQLDB ===
EntityAgent.update(stale book) -> jakarta.persistence.OptimisticLockException
EntityManager.merge(stale book) -> jakarta.persistence.OptimisticLockException
EntityAgent.update(row not in db) -> jakarta.persistence.OptimisticLockException
EntityAgent.upsert(new versioned) -> jakarta.persistence.OptimisticLockException
That is output/09-same-operations-two-databases.txt. On HSQLDB everything fails the way you would hope: OptimisticLockException. On H2 the EntityManager path is correct, but the agent path throws a bare org.hibernate.HibernateException with the message “The database returned no natively generated values”. The same bare exception came back for updating a row that does not exist and for upsert() of a new versioned row.
Catch the wrong type and a conflict looks like a crash. If your retry logic catchesOptimisticLockException, then on H2 with CR1 a staleEntityAgent.update()will sail past it. The H2 path wraps the update inselect version from final table (...), and when no row matches it seems to treat the empty result as a failure to read generated values. That is my reading of the evidence, not a confirmed root cause. The H2 assertions in the test are pinned to this CR1 behaviour, so they will fail the day it is fixed.
Intermediate: how to run this against your own database
The portability test builds its factory through the small Lab.Db enum in Lab.java. To check a database you care about, add an entry with its JDBC URL and dependency, then run Demo05Test and Demo09Test. Those two are the ones where CR1 behaved differently between databases.
Going deeper
- Demo09Test.java — the full test
- Soft Delete in Hibernate 7 — another case where the SQL differs by database
Should you use an EntityAgent at all?
Use the table as a first filter, not a rule:| Situation | Better fit |
|---|---|
| Loading or importing thousands of rows | EntityAgent with insertMultiple |
| Read, change, write one row that you control | either; the agent needs an explicit update() |
| Domain logic that edits an object graph | EntityManager (dirty checking does the writes) |
| Code that loads the same entity repeatedly | EntityManager (it caches within the transaction) |
| Anything you cannot test on your production database | wait for Hibernate 8.0 final |
My honest advice. Do not put Hibernate 8.0.0.CR1 in production. It is a release candidate on a milestone specification, and in my own runs it threw the wrong exception type on H2, rejected or crashed on lock modes on HSQLDB, and could notupserta versioned entity. For most CRUD code theEntityManageris still the right tool, and its change tracking is a feature, not overhead. Reach for an agent when you have measured a bulk-write problem, and remember that if you are on Hibernate 7 today,StatelessSessionalready gives you most of the ideas here with slightly different method signatures.
Further reading
- hibernate8-jpa4-demo — the companion repository; run
./scripts/run-all.shto regenerate every transcript - Multi-Tenancy with Hibernate 7 and Spring Boot 4
- Soft Delete in Hibernate 7
- Spring Data JPA Projections
- Hibernate 8.0 What’s New
- Hibernate 8.0 migration guide
- the JPA 4 M1 announcement
- Jakarta Persistence 4.0 specification page
- Hibernate ORM 8.0 release page
No Comments yet!