Transactions with @Transactional
A single @Transactional annotation hides an entire proxy-based mechanism underneath. This module is what actually happens when you add it — and the specific ways it silently fails to do anything at all.
Learning objectives
- Beginner: Add @Transactional to a service method that performs multiple related writes.
- Intermediate: Explain why calling a @Transactional method from another method in the SAME class doesn't start a transaction.
- Advanced: Choose the correct propagation and isolation level for a method that must run inside an existing transaction versus one that must always start a new one.
◆ The problem
Transferring money between two accounts means debiting one row and crediting another. If your process crashes after the debit but before the credit, you've silently destroyed money — the database has no idea these two writes were supposed to be one atomic unit unless you tell it.
@Transactional tells Spring: wrap this method's database operations in a single transaction, and if anything inside it throws an unchecked exception, roll EVERYTHING back so the database looks exactly as if the method was never called.
@Transactional public void transfer(Long fromId, Long toId, BigDecimal amount) { Account from = accountRepository.findById(fromId).orElseThrow(); Account to = accountRepository.findById(toId).orElseThrow(); from.debit(amount); to.credit(amount); // both saved, or neither — never just one }
Spring never modifies your class's bytecode to add transaction logic directly. Instead, at startup, Spring wraps any bean with a @Transactional method in a dynamically generated PROXY object — a subclass (or interface implementation) that intercepts every call to that method, starts a transaction before your real code runs, and commits or rolls back after it returns.
⚠ Common real-world trap
This is precisely why calling a @Transactional method from ANOTHER method in the same class does nothing — this.transfer(...) is a plain Java method call on the real object, completely bypassing the proxy that would have started the transaction. The exact same self-invocation problem covered for @PreAuthorize in the Spring Security category applies here too, because both rely on the identical proxy mechanism.
The fix is the same: call the @Transactional method through a different bean (inject the proxy and call through it), never through this.
| Propagation | Behavior |
|---|---|
| REQUIRED (default) | Join the existing transaction if one is active; start a new one if not. |
| REQUIRES_NEW | Always suspend any existing transaction and start a brand new, independent one. |
| NESTED | Start a savepoint inside the existing transaction — a rollback here only undoes work since that savepoint, not the whole outer transaction. |
| MANDATORY | Throw an exception if there is no existing transaction — this method must never run standalone. |
REQUIRES_NEW matters in a specific real scenario: logging an audit record that must survive even if the main business operation later rolls back — put the audit-log write in its own REQUIRES_NEW method so it commits independently.
Isolation controls how much one transaction can see of another transaction's uncommitted changes — this is the exact same concept (dirty reads, non-repeatable reads, phantom reads) covered in full depth in Databases Mastery's Transactions category, just configured here via @Transactional(isolation = Isolation.READ_COMMITTED).
▲ Pitfall
By default, @Transactional only rolls back on unchecked exceptions (RuntimeException and its subclasses) — a checked exception does NOT trigger a rollback unless you explicitly say @Transactional(rollbackFor = Exception.class). Throwing a checked IOException from inside a transactional method and expecting an automatic rollback is one of the most common transaction bugs in real codebases.
Want a visual for this concept?
Generate a diagram tailored to “Transactions with @Transactional” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →