Propagation, rollback rules and flush timing
The three transaction behaviours that surprise people in production.
Open this lesson in the learning hubKey points
- Default propagation is
REQUIRED: join the caller transaction, or start one if there is none. Everything then commits or rolls back together. REQUIRES_NEWsuspends 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
RuntimeExceptionandErrorbut commits on a checked exception. UserollbackForwhen a checked exception should also roll back. - Marking a transaction rollback-only is sticky. Catching an exception from an inner
REQUIREDmethod and continuing leads toUnexpectedRollbackExceptionat 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 = truelets 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.