Spring @Transactional – Transactions Made Simple
🎧 Focus sounds
Soft beat sounds that many learners like while reading. Pick one, press Play, and keep studying.
Keep the volume low. These sounds are for comfort while studying, not a medical treatment. If you have epilepsy or are sensitive to pulsing sounds, use the background noise only.
In this lesson
In the JPA course you wrote begin(), commit() and rollback() by hand. In Spring, you write one annotation: **@Transactional**. Spring’s AOP proxy opens a transaction before your method and commits or rolls back after it.
All or nothing, animated#
Try a transfer that fits the balance (like 300) and one that doesn’t (1500):
A real example#
package com.vitalstack;
import jakarta.persistence.*;
@Entity
public class Account {
@Id private String owner;
private int balance;
protected Account() { }
public Account(String owner, int balance) { this.owner = owner; this.balance = balance; }
public void withdraw(int amount) { balance -= amount; }
public void deposit(int amount) { balance += amount; }
public int getBalance() { return balance; }
}
package com.vitalstack;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AccountRepository extends JpaRepository<Account, String> { }
package com.vitalstack;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BankService {
private final AccountRepository accounts;
public BankService(AccountRepository accounts) { this.accounts = accounts; }
@Transactional
public void transfer(String from, String to, int amount) {
Account a = accounts.findById(from).orElseThrow();
Account b = accounts.findById(to).orElseThrow();
a.withdraw(amount); // UPDATE pending (dirty checking)
if (a.getBalance() < 0) {
throw new IllegalStateException("Insufficient funds"); // → ROLLBACK: the withdraw is undone
}
b.deposit(amount);
} // no exception → COMMIT
@Transactional(readOnly = true) // a read-only transaction for queries
public int totalMoney() {
return accounts.findAll().stream().mapToInt(Account::getBalance).sum();
}
}
package com.vitalstack;
import org.springframework.boot.*;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class Main {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(Main.class);
app.setWebApplicationType(WebApplicationType.NONE);
app.run(args).close();
}
@Bean
CommandLineRunner demo(AccountRepository repo, BankService bank) {
return args -> {
repo.save(new Account("A", 1000));
repo.save(new Account("B", 500));
bank.transfer("A", "B", 300);
System.out.println("after 300: A=" + repo.findById("A").get().getBalance()
+ ", B=" + repo.findById("B").get().getBalance());
try {
bank.transfer("A", "B", 1500);
} catch (IllegalStateException e) {
System.out.println("failed: " + e.getMessage());
}
System.out.println("after 1500: A=" + repo.findById("A").get().getBalance()
+ ", B=" + repo.findById("B").get().getBalance());
};
}
}
# application.properties
spring.main.banner-mode=off
logging.level.root=warn
spring.jpa.open-in-view=false
Output:
after 300: A=700, B=800
failed: Insufficient funds
after 1500: A=700, B=800
The failed transfer changed nothing: atomic, as promised.
Rollback rules: the checked-exception surprise#
By default Spring rolls back on unchecked exceptions (RuntimeException, Error), but commits on checked exceptions (Exception, IOException)!
@Transactional
public void importCourses(Path file) throws IOException {
repo.save(new Course("A"));
throw new IOException("file broken"); // ⚠️ the save is COMMITTED by default
}
@Transactional(rollbackFor = Exception.class) // ✅ roll back on any exception
public void importCoursesSafely(Path file) throws IOException { ... }
Many teams use rollbackFor = Exception.class everywhere, or only throw unchecked exceptions from services.
Propagation: what if a transaction is already running?#
| Propagation | Behaviour |
|---|---|
REQUIRED (default) | join the existing transaction, or start a new one |
REQUIRES_NEW | suspend the current one and start an independent transaction |
SUPPORTS | join if one exists; otherwise run without |
MANDATORY | must be called inside a transaction, else error |
NOT_SUPPORTED | suspend any transaction and run without |
NEVER | error if a transaction exists |
NESTED | savepoint inside the current transaction (JDBC only) |
Classic REQUIRES_NEW use: an audit log that must be saved even when the main operation fails:
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String event) { auditRepo.save(new AuditEntry(event)); } // commits on its own
}
readOnly and isolation#
@Transactional(readOnly = true) // no dirty checking/flush; may route to read replicas
public List<CourseView> listCourses() { ... }
@Transactional(isolation = Isolation.SERIALIZABLE, timeout = 5)
public void bookSeat(...) { ... }
The pitfalls (very common in interviews)#
- Self-invocation: calling a
@Transactionalmethod from another method in the same class bypasses the proxy, so no transaction starts. Move it to another bean. - Private methods:
@Transactionalon aprivatemethod does nothing. - Catching the exception inside: if you catch it within the transactional method, Spring never sees it and commits. Re-throw, or call
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(). - Checked exceptions commit by default (see above).
- Long transactions: don’t call slow external APIs or send emails inside a transaction: it holds database connections and locks.
- **Putting
@Transactionalon controllers:** keep it on the service layer, where business operations live.
“UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only” means an inner REQUIRED method threw an exception (marking the shared transaction for rollback) and an outer method caught it and tried to commit. Let the exception propagate, or use REQUIRES_NEW for truly independent work.
Exercise#
- Run the example, then remove
@Transactionalfromtransfer. What are the balances after the failed transfer now? - Make
transferthrow a checkedExceptioninstead and observe that the withdrawal is committed. Fix it withrollbackFor. - Add an
AuditServicewithREQUIRES_NEWthat records every transfer attempt, including failed ones. - Move
transferinto a private helper called from a public non-transactional method in the same class. Does rollback still work?
Hint for 1
Surprise: even the successful transfer isn’t saved (A=1000, B=500). Without a transaction, findById returns detached objects, so dirty checking never runs. If you add accounts.save(a) and accounts.save(b), each save commits on its own, and a failure between them makes money disappear. That’s exactly what a transaction prevents.
Interview corner#
- How does @Transactional work? A proxy (AOP) begins a transaction before the method and commits/rolls back after it, using a PlatformTransactionManager.
- Default rollback behaviour? Roll back on RuntimeException and Error; commit on checked exceptions unless
rollbackForis set. - REQUIRED vs REQUIRES_NEW? Join the current transaction vs suspend it and run an independent one.
- Why doesn’t @Transactional work on internal calls? Self-invocation bypasses the proxy.
Summary#
@Transactionalon service methods gives all-or-nothing behaviour.- Roll back on checked exceptions with
rollbackFor. - Propagation controls joining vs new transactions; readOnly for queries.
- Beware self-invocation, private methods, swallowed exceptions and long transactions.
Next: Spring MVC, the web layer.
Quick quiz
3 questions. Check what stuck and earn 2 XP for each right answer.
Progress is saved in this browser. Create a free account to keep it on every device.
Get new lessons in your inbox
One email when we publish a new tutorial or guide. No spam, unsubscribe in one click.