Entity Lifecycle, Caching & Transaction Boundaries
Closing out Part 5: the full entity state machine, the two-level cache, and exactly where a transaction boundary sits relative to all of it.
Every JPA entity is in exactly one of four states at any moment, and most confusing Hibernate bugs ("why didn't my change save," "why did this insert happen twice") come down to not knowing which state an object is actually in — directly extending Chapter 12 §2's state table into the full picture.
| State | Meaning | How you get there |
|---|---|---|
| Transient | A plain Java object — JPA has never heard of it; no row exists. | new Author() |
| Managed | Tracked by the persistence context (Chapter 12) — field changes are dirty-checked and flushed automatically, no explicit save needed. | entityManager.persist(entity), or loading it via findById() / a query |
| Detached | Was managed once, but its persistence context has since closed — the object still holds data, but changes to it are no longer tracked or saved. | The transaction/EntityManager ends while you're still holding a reference |
| Removed | Marked for deletion; still tracked until commit, when the row is actually deleted. | entityManager.remove(entity) |
Author author = new Author("Rowling"); // Transient — no row, not tracked authorRepository.save(author); // Managed — tracked; dirty checking is now live // ... transaction ends here ... author.setName("J.K. Rowling"); // Detached — this change is silently NEVER saved, no error
That silent-loss line — editing a Detached entity and having nothing happen — is exactly why Chapter 12's "dirty checking only works inside an open persistence context" rule matters in practice, not just in theory.
💻 Code example
Author author = new Author("Rowling"); // Transient — no row, not tracked authorRepository.save(author); // Managed — tracked; dirty checking is now live // ... transaction ends here ... author.setName("J.K. Rowling"); // Detached — this change is silently NEVER saved, no error
| First-level cache | Second-level cache | |
|---|---|---|
| Scope | Per EntityManager /transaction — this is the persistence context itself (Chapter 12) | Shared across the entire application (or cluster, with a distributed cache provider) |
| Enabled by default? | Yes — always on, cannot be disabled | No — must be explicitly configured and opted into per entity |
| Lifetime | Ends when the transaction/EntityManager closes | Survives across transactions and requests, until explicit eviction or TTL expiry |
@Entity @Cacheable @org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE) public class Country { @Id private String code; private String name; // rarely-changing reference data is the classic 2nd-level cache use case }
▲ Common mistake
Enabling second-level cache for frequently-updated entities (e.g. an Order that changes status constantly) creates real cache staleness and invalidation complexity for very little read-performance benefit — reserve it for genuinely slow-changing, read-heavy reference data (countries, currencies, product categories), not your core transactional entities.
💻 Code example
@Entity @Cacheable @org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE) public class Country { @Id private String code; private String name; // rarely-changing reference data is the classic 2nd-level cache use case }
◆ The problem
A lazily-loaded relationship (@OneToMany(fetch = FetchType.LAZY)) is really a proxy that fetches its real data on first access — but that fetch needs an open EntityManager /persistence context to actually run a query through. Access it after the transaction has ended, and you get LazyInitializationException, not silently stale or empty data.
@Transactional public Author getAuthor(Long id) { return authorRepository.findById(id).orElseThrow(); // transaction ends when this method returns } // in the controller, OUTSIDE any transaction: Author author = authorService.getAuthor(1L); author.getBooks().size(); // LazyInitializationException — no persistence context left to fetch through
This directly connects back to Chapter 09's propagation and Chapter 12's persistence-context lifetime: the transaction boundary is the persistence context's lifetime in the standard Spring+JPA setup, so "access lazy data only while the transaction is still open" and "access lazy data only while the persistence context is still open" are the same rule stated two ways.
| Fix | How |
|---|---|
| Fetch what you need inside the transaction | Return a DTO mapped while still inside the @Transactional method, rather than the raw entity. |
| JOIN FETCH | Explicitly fetch the relationship eagerly for this specific query, in one round trip. |
| Open Session in View (discouraged) | Keeps the persistence context open for the entire web request — convenient, but hides N+1 queries and blurs the transaction boundary; generally considered an anti-pattern for anything beyond small apps. |
💻 Code example
@Transactional public Author getAuthor(Long id) { return authorRepository.findById(id).orElseThrow(); // transaction ends when this method returns } // in the controller, OUTSIDE any transaction: Author author = authorService.getAuthor(1L); author.getBooks().size(); // LazyInitializationException — no persistence context left to fetch through
Want a visual for this concept?
Generate a diagram tailored to “Entity Lifecycle, Caching & Transaction Boundaries” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →