advanced~4h

Transactions — @Transactional, Propagation & Isolation

Every persistence operation in this course happens inside a transaction — this chapter covers exactly how @Transactional creates and manages it, and the two sharp edges (self-invocation, checked-exception rollback) that surprise most developers at least once.

Learning objectives

  • Explain why calling an @Transactional method from within the same class silently skips transactional behavior.
  • Choose the correct propagation type (REQUIRED, REQUIRES_NEW, NESTED) for a given nested-call scenario.
  • Explain why checked exceptions don't trigger automatic rollback by default, and how to fix that.

Every persistence operation in this course so far assumed a transaction was already open around it — and that's not a coincidence, because a transaction's boundary and the persistence context's (Chapter 4) boundary are, in the standard Spring+JPA setup, the SAME boundary. This chapter is specifically about what that shared boundary means for flushing, dirty checking, and lazy loading — the generic Spring mechanics of @Transactional itself (the AOP proxy, self-invocation, propagation types, rollback rules) are covered in full depth in Transaction Mastery's own chapters on the subject; this chapter assumes you can look there for that and focuses on the angle specific to JPA.

📖 Story

Imagine a relay race where each runner needs to know: do I start a brand-new race, continue the one already running, or pause it and run a separate one before returning? Get this wrong and you duplicate work, lose the baton, or accidentally run two races that were supposed to be one.

Here's the trap almost every Java developer falls into at least once:

@Service public class OrderService { @Transactional public void placeOrder(Order order) { saveOrder(order); // calling ANOTHER method in the SAME class logAuditEvent(order); } @Transactional public void saveOrder(Order order) { // ⚠️ This annotation does NOTHING when called this way! orderRepository.save(order); } }

Because saveOrder is called from WITHIN the same class (this.saveOrder(...), even though it doesn't look like it), Spring's proxy — the thing that actually makes @Transactional work — never gets involved. The call just goes directly to the real method, bypassing the transaction machinery entirely.

@Transactional — Spring's declarative annotation wrapping a method in a transaction, using AOP (a proxy) rather than manual begin/commit/rollback calls. Propagation — how a transactional method behaves when called from within an already-active transaction. Isolation level — how strictly a transaction is shielded from other concurrent transactions' in-progress changes. Rollback rule — which exceptions trigger an automatic rollback (unchecked exceptions do, by default; checked exceptions do NOT).

The self-invocation trap, REQUIRES_NEW, and the checked-exception rollback rule are the three edges worth knowing purely as @Transactional mechanics, and Transaction Mastery covers all three (plus every other propagation type and isolation level) from that Spring-mechanics angle in real depth — quick summary here, since the rest of this chapter builds on it:

  • Self-invocation silently skips the transaction. Calling saveOrder(order) on this from inside another method of the SAME @Transactional bean never goes through Spring's proxy — the @Transactional on saveOrder does nothing. Fix: move it to a different bean.
  • REQUIRES_NEW genuinely suspends and starts a separate transaction — useful for something like an audit log that should survive even if the surrounding transaction later rolls back.
  • Checked exceptions don't trigger rollback by default — only unchecked exceptions do, unless you add rollbackFor = SomeCheckedException.class explicitly.

Why this chapter exists on top of that: the persistence context shares the transaction's boundary

In the standard Spring Data JPA setup, @Transactional doesn't just wrap your method in a database transaction — it also opens the EntityManager/persistence context (Chapter 4) for the duration of that same method, and closes it the instant the transaction ends. That single fact is what actually explains three things you've already hit in earlier chapters, once you connect them back to it:

  1. Why LazyInitializationException (Chapter 5, Chapter 20) happens specifically OUTSIDE a @Transactional method — a lazy collection needs an open persistence context to fetch through, and the context closes the moment the transaction commits, not sometime later.
  2. Why dirty checking (Chapter 4) doesn't need an explicit save() call — Hibernate compares every managed entity's current state against its loaded snapshot at FLUSH time, and flush happens automatically as part of the SAME transaction commit that @Transactional is managing — you never see two separate steps because they're bound to the same boundary.
  3. Why an OptimisticLockException (Chapter 14) surfaces where it does — the @Version check runs as part of the UPDATE statement Hibernate issues at flush, which happens at commit time; setting a field earlier in the method never fails immediately, only the eventual flush/commit can.
@Transactional public void updateOrderStatus(Long orderId, String status) { Order order = orderRepository.findById(orderId).orElseThrow(); order.setStatus(status); // no save() call — just a field set on a managed entity // nothing hits the database yet: the UPDATE (with the @Version check) is generated // at flush, which happens right before this transaction commits, not right here }

💻 Code example

@Transactional public void updateOrderStatus(Long orderId, String status) { Order order = orderRepository.findById(orderId).orElseThrow(); order.setStatus(status); // no save() call — just a field set on a managed entity // nothing hits the database yet: the UPDATE (with the @Version check) is generated // at flush, which happens right before this transaction commits, not right here }

Spring implements @Transactional using a JDK dynamic proxy or a CGLIB subclass proxy — a TransactionInterceptor wraps your bean, and every EXTERNAL call to an @Transactional method routes through it first, which begins a transaction, invokes your real method, and commits or rolls back based on the outcome (Transaction Mastery covers this proxy mechanism itself in full).

What's specific to JPA is what happens to the EntityManager around that same boundary: @Transactional (via JpaTransactionManager) binds a fresh EntityManager to the current thread when the transaction starts, and every repository call and entityManager operation for the rest of that method reuses the SAME bound EntityManager — which is exactly why every read within one @Transactional method shares one first-level cache (Chapter 4) and sees each other's uncommitted writes. When the transaction commits, JpaTransactionManager triggers a final flush (reconciling any remaining dirty entities), then commits the underlying database transaction, then closes the EntityManager — in that order. REQUIRES_NEW (the audit-logging pattern above) suspends the current thread-bound transaction AND its EntityManager, binds a genuinely new pair, completes that pair fully, then resumes the original suspended transaction and its original EntityManager.

  • This chapter's audit-logging example — using REQUIRES_NEW so an audit record persists even if the main order transaction later rolls back — is a completely standard real pattern.
  • The self-invocation trap from this chapter's opening story is one of the most commonly reported real Spring bugs — search for it and you'll find it's caught countless engineers, usually discovered in production when data wasn't rolled back as expected.
  • A payment service's checked PaymentDeclinedException, exactly this chapter's example, needing explicit rollbackFor is a real, dangerous correctness bug if missed.
  • Never assume calling an @Transactional method from within the SAME class will behave transactionally — this chapter's story shows exactly why it won't; extract it to a separate bean if you need the boundary to apply.
  • Explicitly configure rollbackFor for any checked exception that should trigger a rollback — never rely on the default.
  • Reserve REQUIRES_NEW for genuinely independent units of work, like this chapter's audit log.

⚠️ Why this keeps happening

@Transactional's proxy mechanism is invisible in ordinary code — you add an annotation and it "just works" for the vast majority of calls, which is exactly why the two sharp edges in this chapter (self-invocation, checked-exception rollback) surprise developers who've never had to think about the proxy underneath.

  • Calling an @Transactional method from another method in the SAME class — exactly this chapter's opening trap — silently getting no transactional behavior at all.
  • Assuming a checked exception automatically rolls back, because that's how unchecked exceptions behave — checked exceptions need explicit rollbackFor, as this chapter's payment example showed.
  • Marking a method readOnly = true and expecting it to prevent writes at the database level — it's an optimization hint, not an enforced guarantee.

readOnly = true on a purely read-only transactional method is a genuine, easy optimization — Hibernate can skip dirty-checking overhead for entities loaded within it. Keeping transactions short (avoiding slow, non-database work inside the transactional boundary) reduces how long connections and locks are held.

A missing rollbackFor for a security-relevant checked exception (like this chapter's payment example) can leave partially-committed, inconsistent state that a security-sensitive operation depended on being atomic.

Monitor transaction duration distribution in production — an unusually long-running @Transactional method is both a concurrency risk and often a sign non-database work has crept inside a transactional boundary.

Establish a team convention requiring rollbackFor to be explicitly reviewed whenever a transactional method can throw a checked exception — a standing code-review checklist item, since it's easy to miss.

  1. Reproduce this chapter's self-invocation trap exactly — call an @Transactional method from another method in the same class, and confirm no transaction is actually started.
  2. Configure a method to throw a checked exception with no rollbackFor, confirm the transaction commits anyway, then add rollbackFor and confirm it now rolls back.
  3. Build this chapter's REQUIRES_NEW audit-logging example, force the outer transaction to fail after the inner one commits, and confirm the audit record survives.

✓ Quick recap

  • @Transactional works via an AOP proxy — calling a transactional method from within the same class bypasses that proxy entirely (this chapter's self-invocation trap).
  • REQUIRED joins an existing transaction or starts a new one; REQUIRES_NEW always suspends and starts a genuinely independent one.
  • Only unchecked exceptions trigger automatic rollback by default — checked exceptions need explicit rollbackFor.

Want a visual for this concept?

Generate a diagram tailored to “Transactions — @Transactional, Propagation & Isolation” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to N+1 Queries & Fetching Strategies — JOIN FETCH, Entity Graph, Batch Fetch← Back to all Spring Data JPA & Hibernate Mastery chapters