beginner~2h

@Transactional Fundamentals

Everything in Parts 1–3 was database theory. This chapter is where it becomes one annotation — and the single most misunderstood rule in Spring: not every exception rolls back your transaction.

@Service public class WalletService { @Transactional public void transfer(Long fromId, Long toId, BigDecimal amount) { Wallet from = walletRepository.findById(fromId).orElseThrow(); Wallet to = walletRepository.findById(toId).orElseThrow(); from.debit(amount); // may throw InsufficientFundsException to.credit(amount); // method returns normally → Spring commits automatically // method throws an unchecked exception → Spring rolls back automatically } }

@Transactional tells Spring "wrap every call to this method in a database transaction, commit if it returns normally, roll back if it throws" — but exactly which exceptions trigger a rollback is where most developers get it wrong (§08.2).

💻 Code example

@Service public class WalletService { @Transactional public void transfer(Long fromId, Long toId, BigDecimal amount) { Wallet from = walletRepository.findById(fromId).orElseThrow(); Wallet to = walletRepository.findById(toId).orElseThrow(); from.debit(amount); // may throw InsufficientFundsException to.credit(amount); // method returns normally → Spring commits automatically // method throws an unchecked exception → Spring rolls back automatically } }

▲ The single most common Spring transaction bug

By default, Spring only rolls back on unchecked exceptions (subclasses of RuntimeException) and Error. A checked exception (any Exception that isn't a RuntimeException) thrown from a @Transactional method, by default, does not trigger a rollback — Spring commits the partial work anyway.

@Transactional public void transfer(Long fromId, Long toId, BigDecimal amount) throws InsufficientFundsCheckedException { from.debit(amount); to.credit(amount); if (someExternalValidationFails()) { throw new InsufficientFundsCheckedException(); // a CHECKED exception } // SURPRISE: Spring commits from.debit() and to.credit() ANYWAY, despite the exception, // because InsufficientFundsCheckedException doesn't extend RuntimeException }
@Transactional(rollbackFor = InsufficientFundsCheckedException.class) public void transfer(Long fromId, Long toId, BigDecimal amount) throws InsufficientFundsCheckedException { // now Spring rolls back for this specific checked exception too }
ThrownDefault behavior
Unchecked exception (extends RuntimeException)ROLLBACK (default)
ErrorROLLBACK (default)
Checked exception (extends Exception, not RuntimeException)COMMIT anyway (default) — unless rollbackFor is specified

The most common production-safe convention, precisely because this default is so easy to trip over: use unchecked exceptions for anything that should roll back a transaction, and reserve checked exceptions for things a caller is expected to explicitly handle and recover from without wanting the transaction undone.

💻 Code example

@Transactional public void transfer(Long fromId, Long toId, BigDecimal amount) throws InsufficientFundsCheckedException { from.debit(amount); to.credit(amount); if (someExternalValidationFails()) { throw new InsufficientFundsCheckedException(); // a CHECKED exception } // SURPRISE: Spring commits from.debit() and to.credit() ANYWAY, despite the exception, // because InsufficientFundsCheckedException doesn't extend RuntimeException }
@Transactional(readOnly = true) public List<OrderSummary> getOrderHistory(Long customerId) { return orderRepository.findSummariesByCustomer(customerId); }

◆ Under the hood

readOnly = true is a hint, not an enforced restriction — Spring passes it down to the underlying Connection /Hibernate session, which can use it to skip dirty-checking overhead (Chapter 12) for loaded entities and, on some drivers, route the connection to a read replica. It doesn't actually prevent a write statement from running if your code contains one — that's a common misconception; treat it as a performance and intent signal, not a safety guarantee.

💻 Code example

@Transactional(readOnly = true) public List<OrderSummary> getOrderHistory(Long customerId) { return orderRepository.findSummariesByCustomer(customerId); }
@Transactional(timeout = 5) // seconds — rolls back automatically if not committed within this window public void processLargeBatch(List<Order> orders) { // if this takes longer than 5 seconds, Spring forces a rollback with a TransactionTimedOutException }

▲ Common mistake

Long-running transactions (batch jobs, report generation) holding locks or an old MVCC snapshot open for minutes can seriously degrade concurrent throughput for everyone else touching the same rows/table (Chapter 07, Chapter 21). An explicit timeout is a safety net that turns "this accidentally ran for 10 minutes and blocked production" into a clean, fast failure instead.

💻 Code example

@Transactional(timeout = 5) // seconds — rolls back automatically if not committed within this window public void processLargeBatch(List<Order> orders) { // if this takes longer than 5 seconds, Spring forces a rollback with a TransactionTimedOutException }

Want a visual for this concept?

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

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Propagation & Isolation in Spring← Back to all Transaction Mastery chapters