๐งพ Spring @Transactional โ Rollback Rules, Propagation, and Read-Only Patterns¶
A hands-on reference for Spring and Jakarta/JTA transaction management.
Focuses on what actually bites developers in production: rollback semantics, propagation chains, and read-only optimizations.
๐งฉ 1. The essence of @Transactional¶
Transactions wrap a series of DB operations into an atomic unit โ all succeed or all fail.
@Transactional
public void process() {
repo.save(...);
repo.update(...);
if (somethingWrong()) throw new RuntimeException();
}
If an unchecked exception occurs, Spring marks the transaction for rollback.
Default rules¶
| Type of Exception | Spring Default | Jakarta Default | Fix / Override |
|---|---|---|---|
| RuntimeException / Error | rollback | rollback | โ |
| Checked Exception | commit | commit | add rollbackFor=Exception.class (Spring) or rollbackOn=Exception.class (Jakarta) |
| Caught exception | commit | commit | call setRollbackOnly() manually |
โ๏ธ 2. Rollback in practice¶
A) Unchecked exception โ rollback automatically¶
@Transactional
public void placeOrder(Long userId) {
orderRepo.save(new Order(...));
paymentRepo.save(new Payment(...));
throw new IllegalStateException("payment gateway down"); // rollback
}
Both inserts roll back.
B) Checked exception โ commits unless configured¶
@Transactional
public void placeOrderChecked(Long userId) throws IOException {
orderRepo.save(...);
throw new IOException("printer failed"); // commits
}
C) Fix: include checked in rollback¶
@Transactional(rollbackFor = Exception.class)
public void placeOrderCheckedRollback(Long userId) throws IOException {
orderRepo.save(...);
throw new IOException("printer failed"); // rolls back
}
D) Jakarta equivalent¶
import jakarta.transaction.Transactional;
@Transactional(rollbackOn = Exception.class)
public void placeOrderJakarta(Long userId) throws IOException { ... }
๐ 3. Propagation behavior¶
Each transaction can join, suspend, or create a new one.
| Propagation | Meaning | Typical Use |
|---|---|---|
| REQUIRED (default) | Join if exists, else start new | Normal service calls |
| REQUIRES_NEW | Suspend current, start new | Auditing/logging |
| NESTED | Create savepoint (rollback inner only) | Partial rollback inside parent |
| SUPPORTS | Run within tx if one exists | Read-only operations |
| NOT_SUPPORTED | Run non-transactionally | Reporting |
| NEVER | Throw error if a tx exists | Safety guard |
Example¶
@Transactional
public void placeOrder() {
saveOrder(); // same tx
audit_requiresNew(); // separate tx
throw new RuntimeException("outer fails");
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void audit_requiresNew() {
auditRepo.save(new AuditLog("order saved"));
}
Audit commits, even though the outer one rolls back.
๐ 4. Read-only transactions¶
@Transactional(readOnly = true)
public List<Order> listRecentOrders(int limit) {
return orderRepo.findTopNByOrderByCreatedAtDesc(limit);
}
Hints ORM and drivers that no writes will occur. Improves performance (skips dirty checking, can use read-only DB replicas).
๐งฎ 5. Isolation levels and timeout¶
Spring-only attributes:
@Transactional(
isolation = Isolation.REPEATABLE_READ,
timeout = 10,
readOnly = true
)
public List<User> queryActiveUsers() { ... }
Isolation.READ_COMMITTED(default) โ prevents dirty reads.Isolation.REPEATABLE_READโ consistent view of rows.Isolation.SERIALIZABLEโ full locking, slowest but safest.timeout(seconds) โ rollback if exceeded.
Jakarta/JTA @Transactional lacks these attributes โ you configure them via the datasource.
๐งฐ 6. Catching exceptions & marking rollback¶
If you catch an exception, the framework assumes everythingโs fine โ it will commit unless you mark rollback manually.
import org.springframework.transaction.interceptor.TransactionAspectSupport;
@Transactional
public void catchButRollback() {
try {
doWork();
} catch (Exception e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}
Jakarta equivalent:
import jakarta.transaction.TransactionSynchronizationRegistry;
import jakarta.annotation.Resource;
@Resource
TransactionSynchronizationRegistry tsr;
@jakarta.transaction.Transactional
public void catchButRollbackJta() {
try {
doWork();
} catch (Exception e) {
tsr.setRollbackOnly();
}
}
๐งฑ 7. Self-invocation pitfall (proxy mechanics)¶
@Service
public class MyService {
@Transactional
public void outer() {
inner(); // self-call bypasses proxy โ no tx applied
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() { ... }
}
Spring proxies intercept external calls only. Solutions:
- Call via another bean (
otherBean.inner()). - Use interface-based proxy injection.
- Or enable AspectJ mode (
@EnableAspectJAutoProxy(exposeProxy = true)).
โ๏ธ 8. Choosing between Spring & Jakarta¶
| Feature | Spring @Transactional |
Jakarta/JTA @Transactional |
|---|---|---|
isolation, timeout, readOnly |
โ yes | โ no |
rollbackFor, noRollbackFor |
โ yes | โ no |
propagation (REQUIRES_NEW, NESTED) |
โ yes | limited (TxType.*) |
| Multiple transaction managers | โ yes | โ global TM only |
| XA / JTA compatibility | โ via JtaTransactionManager | โ native |
| Simplicity / portability | moderate | lightweight |
Rule of thumb: Use Spring if you rely on JPA, multiple datasources, or fine-grained control. Use Jakarta if youโre targeting EE containers or minimal portability.
๐งญ 9. Targeting specific transaction managers¶
@Transactional(transactionManager = "ordersTxManager")
public void writeOrder() { ... }
@Transactional(transactionManager = "billingTxManager")
public void writeInvoice() { ... }
Used when your app has multiple datasources or distinct persistence contexts.
๐ 10. Common pitfalls checklist¶
โ Rollback only happens if the framework sees the exception.
โ Checked exceptions need rollbackFor or rollbackOn.
โ Self-calls bypass proxy transactions.
โ readOnly=true is a hint, not a guarantee.
โ Use REQUIRES_NEW for independent commits (audits, logs).
โ Catching exceptions? Set rollback manually.
โ Pick one annotation style consistently.
โ Log transaction boundaries when debugging (DEBUG org.springframework.transaction).
๐งพ Summary Table¶
| Concept | Spring syntax | Jakarta syntax | Notes |
|---|---|---|---|
| Rollback on RuntimeException | โ๏ธ (default) | โ๏ธ (default) | โ |
| Rollback on checked Exception | rollbackFor=Exception.class |
rollbackOn=Exception.class |
must opt-in |
| Independent tx | Propagation.REQUIRES_NEW |
TxType.REQUIRES_NEW |
inner commit survives outer failure |
| Read-only | @Transactional(readOnly=true) |
โ | optimization hint |
| Isolation/timeout | attributes | container config | Spring feature |
| Nested tx | Propagation.NESTED |
โ | savepoint-based, JDBC only |
๐ TL;DR Mental Model¶
- A transaction begins at the first
@Transactionalboundary. - If an exception escapes the boundary, rollback occurs (runtime-only by default).
- Nested calls usually share the same tx unless marked otherwise.
- Caught exceptions commit unless you flag rollback manually.
- Read-only methods are lighter, safer for queries.
- Always log resolved propagation chains when debugging weird commits.
Outcome: You now have a complete transactional playbook โ clear rollback rules, predictable propagation, and a memory-safe mental map for every @Transactional boundary.