The JPA Persistence Context, and the Four Entity States
Two behaviours confuse people more than anything else in JPA. The first is an update that saves without anyone calling save. The second is a LazyInitializationException on an object that was fine a moment ago.
Both come from the same thing: the persistence context, and which of four states an entity is currently in.
The persistence context
The persistence context is a cache of entities scoped to a transaction. Every entity loaded or saved inside the transaction is tracked in it, keyed by primary key, and Hibernate keeps a snapshot of the values as they were when loaded.
At the end of the transaction it compares each tracked entity against its snapshot and issues UPDATE statements for anything that differs. This is dirty checking, and it is not optional.
It also means that loading the same row twice in one transaction returns the same Java object, not two equal ones:
Order a = repository.findById(1L).orElseThrow();
Order b = repository.findById(1L).orElseThrow();
a == b; // true
Transient
A new object, made with new, unknown to JPA and with no row behind it:
Order order = new Order("A-1"); // transient
Changing it does nothing to the database. It becomes managed when you persist it.
Managed
An entity that the persistence context is tracking, either because it was loaded inside the transaction or because it was just persisted. Changes to a managed entity are written out at flush time.
This is the source of the first surprise:
@Transactional
public void rename(long id, String name) {
Order order = repository.findById(id).orElseThrow();
order.setName(name);
// no save() call, and the row is updated anyway
}
The entity is managed, the field differs from the snapshot, and the UPDATE happens on commit. Calling save here changes nothing, which is why you will see it both with and without across a codebase.
The same mechanism is the second surprise, in reverse:
@Transactional
public void audit(long id) {
Order order = repository.findById(id).orElseThrow();
order.setName(order.getName().trim()); // a "harmless" tidy up
}
That writes to the database. Anything you mutate on a managed entity is a write, including changes you made only for the benefit of the code below.
Detached
An entity that was managed and no longer is, because the transaction ended. It still holds data, and it is now an ordinary object that JPA has stopped watching.
Changes to a detached entity are lost unless you merge it back:
Order merged = entityManager.merge(order);
merge does not make your object managed. It copies the state onto a managed instance and returns that one, so merged != order. Continuing to work with the original after a merge is a bug that produces no error at all.
Detachment is also where LazyInitializationException comes from. A lazy association is a proxy that fetches on first access, and it needs an open persistence context to do so. Touch it after the transaction has ended and there is nothing to fetch through. This is the strongest practical argument for mapping to DTOs before returning: the mapping happens while the entity is still managed, and what leaves the service has no proxies in it.
Removed
Scheduled for deletion at the next flush. The object still exists in Java, and the row is still there until the DELETE is issued.
repository.delete(order); // removed, DELETE not necessarily sent yet
Flush is not commit
Flush is when Hibernate translates pending changes into SQL. Commit is when the database makes them permanent. Hibernate flushes automatically before a query that might be affected by pending changes, and always before commit.
This is why an exception can surface at the end of a method with a stack trace pointing nowhere near the line that caused it. The setter ran at line 10, the INSERT was sent at commit, and the constraint violation is reported from the commit.
You can force it earlier with entityManager.flush(), which is a legitimate tool when you need the database generated id or want a violation reported at the line that caused it.
Practical consequences
Keep transactions short but complete. Everything that needs a managed entity has to happen inside one, and everything that does not should happen outside.
Do not pass entities across the transaction boundary. They arrive detached, they carry proxies, and the failure appears in whichever layer touched them next.
Read only when you are only reading. @Transactional(readOnly = true) lets Hibernate skip the snapshots and the dirty check, which on a large result set is a real saving and also stops accidental writes.
Watch what you mutate. In a managed entity, a setter is a database write with a delay on it.