The full picture
@Service
public class TransferService {
@Transactional
public void transfer(...) throws SettlementException {
debit(...);
try {
credit(...);
} catch (DataAccessException e) { // ① caught -> interceptor never sees it
log.warn("credit failed", e); // method returns normally -> COMMIT (debit only!)
}
settle(...); // ② throws CHECKED SettlementException
} // -> default rule: still COMMITS
@Transactional // ③ called via this.recalc() elsewhere
void recalc() { … } // in class -> proxy bypassed
// ④ non-public -> not advised at all
}
// Fixes: rethrow or mark rollbackOnly; @Transactional(rollbackFor = SettlementException.class);
// move recalc() to its own bean; make advised methods public.- Programmatic escape hatch when you must catch:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()— or better, restructure so the exception propagates. - Layer discipline:
@Transactionalbelongs on the service/use-case boundary — one business operation, one transaction. On repositories it fragments atomicity; on controllers it drags HTTP serialization into the TX window. - Keep transactions short: no HTTP calls, no heavy computation inside — you're holding a connection and locks. The connection-pool math (pool size vs concurrent TX duration) is where this becomes an outage.
- Verify wiring in tests: an integration test that asserts rollback actually happened (row absent) catches ①–④ mechanically.