Transactions with @Transactional
A transaction groups database operations so that they all succeed or all fail together. Spring makes this easy with @Transactional, but you must understand how the proxy works to avoid silent mistakes.
Basic example: money transfer
@Service
public class TransferService {
private final AccountRepository accounts;
public TransferService(AccountRepository accounts) { this.accounts = accounts; }
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = accounts.findById(fromId).orElseThrow();
Account to = accounts.findById(toId).orElseThrow();
from.withdraw(amount); // throws if balance is too low
to.deposit(amount);
// commit happens when the method ends normally.
// A RuntimeException rolls BOTH changes back.
}
}Spring wraps the service bean in a proxy. Before your method it opens a transaction, and after it commits (or rolls back on exception). Entity changes made inside the transaction are saved automatically at commit (dirty checking), even without calling save().
Propagation: what if a transactional method calls another one?
| Propagation | Behavior |
|---|---|
| REQUIRED (default) | Join the current transaction, or create one if none exists |
| REQUIRES_NEW | Always start a new transaction and suspend the current one |
| SUPPORTS | Use a transaction if one exists, otherwise run without |
| NOT_SUPPORTED | Run without a transaction (suspend the current one) |
| MANDATORY | Must run inside a transaction, otherwise throw an exception |
| NEVER | Must run without a transaction, otherwise throw an exception |
| NESTED | Nested transaction with a savepoint |
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW) // saved even if the caller rolls back
public void log(String message) { /* save audit row */ }
}Isolation levels
| Isolation | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| READ_UNCOMMITTED | Possible | Possible | Possible |
| READ_COMMITTED | Prevented | Possible | Possible |
| REPEATABLE_READ | Prevented | Prevented | Possible |
| SERIALIZABLE | Prevented | Prevented | Prevented |
The default is the database default (READ_COMMITTED in PostgreSQL and Oracle, REPEATABLE_READ in MySQL InnoDB). Higher isolation gives more safety but less concurrency.
Rollback rules
- By default a transaction rolls back on RuntimeException and Error only.
- Checked exceptions (for example IOException) do NOT roll back by default.
- Use @Transactional(rollbackFor = Exception.class) to roll back on checked exceptions too.
- @Transactional(readOnly = true) is a hint for queries. It can improve performance and avoids accidental writes.
Common pitfalls
@Service
public class OrderService {
public void placeOrder(Order o) {
saveOrder(o); // PITFALL 1: self-invocation, no proxy, NO transaction!
}
@Transactional
public void saveOrder(Order o) { /* ... */ }
@Transactional
public void wrong() {
try {
riskyDatabaseCall();
} catch (Exception e) {
// PITFALL 2: swallowed exception. The transaction still commits
// (or is marked rollback-only by an inner call and fails at commit).
}
}
@Transactional
private void hidden() { } // PITFALL 3: private methods are not proxied
}- Fix for pitfall 1: move the transactional method to another bean, or put @Transactional on the outer method.
- Do not keep transactions open during slow work such as HTTP calls. Long transactions hold database connections and locks.
- A new thread (@Async) does not share the transaction of the caller.
- Put @Transactional on the service layer, not on controllers or repositories.
Programmatic transaction
@Service
public class ReportService {
private final TransactionTemplate tx;
public ReportService(PlatformTransactionManager manager) { this.tx = new TransactionTemplate(manager); }
public void run() {
tx.executeWithoutResult(status -> {
// database work
// status.setRollbackOnly(); // roll back manually
});
}
}Internal workflow of a @Transactional call
caller ─► proxy of TransferService.transfer()
TransactionInterceptor.invokeWithinTransaction
1. TransactionAttributeSource: read @Transactional (propagation, isolation, readOnly, rollbackFor, timeout) from method or class
2. choose the PlatformTransactionManager (JpaTransactionManager for JPA, DataSourceTransactionManager for JDBC)
3. tm.getTransaction(definition)
├─ existing transaction bound to this thread? yes ─► apply PROPAGATION rules (join, suspend, create new)
└─ no ─► create: get a JDBC Connection from the pool, setAutoCommit(false), set isolation / read-only,
create the EntityManager, and BIND both to the thread with TransactionSynchronizationManager (ThreadLocal maps)
4. invoke your method
└─ every repository / JdbcTemplate call asks TransactionSynchronizationManager for the bound Connection / EntityManager
─► ALL calls in the method use the SAME connection = the same database transaction
5a. normal return ─► tm.commit(): flush the persistence context (INSERT/UPDATE statements), COMMIT, run afterCommit callbacks, release the connection
5b. exception ─► rollbackOn(ex)? (RuntimeException/Error or rollbackFor) yes ─► ROLLBACK, else COMMIT
6. unbind resources, return the connection to the Hikari poolThe transaction is tied to the THREAD. New threads (@Async, parallel streams, CompletableFuture) do not share it. Each of them gets its own connection and its own transaction, or none.
The rollback-only trap
@Service
class OrderService {
private final PaymentService payments; // has @Transactional too (propagation REQUIRED)
OrderService(PaymentService payments) { this.payments = payments; }
@Transactional
public void place(Order order) {
try {
payments.charge(order); // throws RuntimeException: the inner proxy marks the SHARED transaction rollback-only
} catch (RuntimeException e) {
log.warn("payment failed, continuing", e); // you swallowed it, but the transaction is already doomed
}
saveOrder(order);
} // commit fails with UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
}
// Fixes: let the exception propagate, OR give charge() propagation = REQUIRES_NEW (own transaction that can fail alone),
// OR do not use a shared transaction for that step.Run code after commit (emails, messages)
@Transactional
public void register(String email) {
userRepository.save(new AppUser(email));
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
mailSender.sendWelcome(email); // only if the transaction really committed
}
});
}
// Simpler and testable: publish an event and use @TransactionalEventListener(phase = AFTER_COMMIT)Isolation demo: lost update and how to avoid it
Two transactions, balance = 100, both do: read balance ─► balance - 30 ─► write
T1: read 100 T2: read 100
T1: write 70 COMMIT T2: write 70 COMMIT ◄── result 70, should be 40 (LOST UPDATE)
Solutions:
1. Atomic SQL: update account set balance = balance - 30 where id = ? and balance >= 30 (no read-modify-write in Java)
2. Optimistic lock: @Version column ─► the second commit fails with ObjectOptimisticLockingFailureException ─► retry
3. Pessimistic lock: select ... for update ─► T2 waits until T1 commits
4. SERIALIZABLE isolation (last resort: retries on serialization failures needed)// Option 1 in Spring Data
@Modifying
@Query("update Account a set a.balance = a.balance - :amount where a.id = :id and a.balance >= :amount")
int withdraw(@Param("id") Long id, @Param("amount") BigDecimal amount); // returns 0 if the balance was too lowTransactions and testing
- @Transactional on a test method rolls back after the test. It keeps tests independent, but it also hides commit-time problems (constraint checks, afterCommit listeners, flush errors). Call repository.flush() to force SQL, and write some tests without @Transactional (use cleanup) for real commit behavior.
- With @SpringBootTest(webEnvironment = RANDOM_PORT) the test client runs on another thread, so the test transaction does NOT cover the server side.
Distributed transactions
A database transaction covers one database. Saving to your database AND sending a Kafka message is two systems. Do not rely on XA/2PC for this. Use the transactional outbox pattern: write the event to an outbox table in the same database transaction, and let a poller or Debezium publish it. Consumers must be idempotent.
Common mistake: Calling a @Transactional method from another method of the same class. The proxy is bypassed, so no transaction is started. Move it to a separate bean.
Interview questions
What is the default rollback behavior?
Rollback for RuntimeException and Error. Checked exceptions commit unless you set rollbackFor.
What is the difference between REQUIRED and REQUIRES_NEW?
REQUIRED joins the existing transaction. REQUIRES_NEW suspends it and creates a separate one that commits or rolls back independently.
Why does @Transactional not work on private methods?
The proxy can only intercept calls that come from outside through public (or protected) methods.
Why does @Transactional not roll back on a checked exception?
By default only RuntimeException and Error trigger rollback. Use rollbackFor = Exception.class, or throw unchecked exceptions.
What is UnexpectedRollbackException?
The transaction was marked rollback-only by an inner call or a caught exception, and the outer method tried to commit. Let the exception propagate or isolate the inner step with REQUIRES_NEW.