Propagation, rollback rules and flush timing

Spring Boot · lesson 31 of 39 · 7 min read

The three transaction behaviours that surprise people in production.

Open this lesson in the learning hub

Key points

  • Default propagation is REQUIRED: join the caller transaction, or start one if there is none. Everything then commits or rolls back together.
  • REQUIRES_NEW suspends the outer transaction and opens a second database connection. If the outer transaction holds a lock the inner one needs, the inner blocks while the outer waits for it to return - a self-deadlock that only a lock timeout breaks.
  • Rollback rules are not what most people expect: Spring rolls back on RuntimeException and Error but commits on a checked exception. Use rollbackFor when a checked exception should also roll back.
  • Marking a transaction rollback-only is sticky. Catching an exception from an inner REQUIRED method and continuing leads to UnexpectedRollbackException at commit, because the shared transaction was already doomed.
  • Writes are not sent when you call save. They are sent at flush, which normally happens at commit - so a constraint violation surfaces in a different stack frame from the code that caused it.
  • readOnly = true lets Hibernate skip dirty checking and the driver may pass the hint downstream. It is an optimisation, not a guarantee that nothing can be written.

Example

@Service
public class PaymentService {

    // Deadlocks against itself: the outer transaction still holds the row lock
    // that the new, independent transaction is waiting for.
    @Transactional
    public void chargeAndAudit(long accountId) {
        accounts.lockForUpdate(accountId);      // outer holds the lock
        audit.writeInNewTransaction(accountId); // inner waits for it - forever
    }

    // Commits. A checked exception does NOT trigger rollback by default.
    @Transactional
    public void transfer() throws InsufficientFundsException {
        ledger.debit(100);
        throw new InsufficientFundsException();  // the debit is COMMITTED
    }

    // Correct: state the rule explicitly.
    @Transactional(rollbackFor = InsufficientFundsException.class)
    public void transferSafely() throws InsufficientFundsException {
        ledger.debit(100);
        throw new InsufficientFundsException();  // now rolled back
    }
}

@Service
class AuditService {

    // Independent: survives a rollback of the caller. That is the legitimate use.
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void writeInNewTransaction(long accountId) { /* ... */ }
}

@Service
class OrderService {

    // Swallowing the exception does not save the transaction.
    @Transactional
    public void process() {
        try {
            inner.doWork();          // REQUIRED - same transaction
        } catch (RuntimeException e) {
            log.warn("continuing", e);   // transaction is already rollback-only
        }
        // commit here throws UnexpectedRollbackException
    }
}

REQUIRES_NEW takes a second connection, checked exceptions commit by default, and the SQL runs at flush - not where you wrote the call.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Spring Boot course, and every lesson in it is listed on the Spring Boot contents page.