@Transactional Deep Dive
How Spring transactions work, propagation and isolation levels, rollback rules, read-only transactions and the most common pitfalls.
ACID in one table
| Property | Guarantee |
|---|---|
| Atomicity | All statements commit or none do |
| Consistency | Constraints hold before and after |
| Isolation | Concurrent transactions don’t see each other’s intermediate state (to a configurable degree) |
| Durability | Committed data survives crashes |
How @Transactional works
@Transactional is implemented with an AOP proxy (see Spring AOP & Proxies). Calls into the bean go through a TransactionInterceptor that asks the PlatformTransactionManager (JpaTransactionManager with JPA) to begin, commit or roll back.
sequenceDiagram
participant C as Controller
participant P as OrderService proxy
participant TM as JpaTransactionManager
participant S as OrderService
participant DB as Database
C->>P: placeOrder(cmd)
P->>TM: getTransaction(REQUIRED)
TM->>DB: BEGIN (connection bound to thread)
P->>S: placeOrder(cmd)
S->>DB: INSERT order, UPDATE stock
S-->>P: return / throw
alt success
P->>TM: commit → flush + COMMIT
else RuntimeException
P->>TM: rollback → ROLLBACK
end
P-->>C: result
@Service
class OrderService {
@Transactional
public OrderId placeOrder(PlaceOrder cmd) {
Order order = orders.save(new Order(cmd.customerId()));
cmd.items().forEach(i -> {
inventory.reserve(i.sku(), i.qty()); // throws InsufficientStockException → everything rolls back
order.addLine(i.sku(), i.qty(), prices.of(i.sku()));
});
return order.id();
}
@Transactional(readOnly = true)
public OrderView get(long id) { ... }
}
Rollback rules
By default Spring rolls back on unchecked exceptions (RuntimeException, Error) and commits on checked exceptions — a frequent surprise.
@Transactional(rollbackFor = Exception.class) // also roll back for checked exceptions
@Transactional(noRollbackFor = NotificationFailedException.class)
Catching an exception inside the method and not rethrowing means the proxy never sees it — the transaction commits. If an inner REQUIRED method throws and an outer method catches it, the shared transaction is already marked rollback-only, and the outer commit fails with UnexpectedRollbackException.
Propagation
What happens when a transactional method calls another transactional method?
| Propagation | Behaviour | Use for |
|---|---|---|
REQUIRED (default) |
Join the current transaction, or start one | Almost everything |
REQUIRES_NEW |
Suspend the current one and start an independent transaction | Audit logs / outbox entries that must persist even if the caller rolls back |
NESTED |
Savepoint inside the current transaction (JDBC savepoints) | Partial rollback of a step |
SUPPORTS |
Join if one exists, else run without | Read helpers |
MANDATORY |
Must be called within a transaction, else throw | Enforcing that callers manage the transaction |
NOT_SUPPORTED |
Suspend any transaction, run without | Long non-transactional work |
NEVER |
Throw if a transaction exists |
@Service
class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String event) { auditRepository.save(new AuditEntry(event)); }
}
Isolation levels
| Level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
READ_UNCOMMITTED |
possible | possible | possible |
READ_COMMITTED (PostgreSQL, Oracle, SQL Server default) |
✗ | possible | possible |
REPEATABLE_READ (MySQL InnoDB default) |
✗ | ✗ | possible* |
SERIALIZABLE |
✗ | ✗ | ✗ |
* InnoDB and PostgreSQL’s snapshot implementations prevent most phantoms at REPEATABLE_READ.
Rather than raising isolation globally, protect specific invariants: optimistic locking with @Version, pessimistic locking with @Lock(LockModeType.PESSIMISTIC_WRITE) (SELECT … FOR UPDATE), or atomic SQL (update stock set qty = qty - :n where sku = :sku and qty >= :n).
Common pitfalls
- Self-invocation — calling a
@Transactionalmethod from the same class bypasses the proxy. - Non-public methods — annotations on
privatemethods are ignored (proxies can’t intercept them). - Checked exceptions commit unless
rollbackForis set. - Swallowed exceptions — catching without rethrowing commits partial work.
- Long transactions — remote HTTP calls inside a transaction hold a DB connection and locks for the duration. Do I/O before or after.
- Side effects before commit — sending an email or Kafka message inside the transaction, then rolling back. Use
@TransactionalEventListener(phase = AFTER_COMMIT)or the transactional outbox pattern. @Transactionalon controllers — keep transaction boundaries in the service layer.
Programmatic transactions
@Service
class ImportService {
private final TransactionTemplate tx;
ImportService(PlatformTransactionManager tm) { this.tx = new TransactionTemplate(tm); }
void importAll(List<Row> rows) {
for (List<Row> chunk : chunks(rows, 500)) {
tx.executeWithoutResult(status -> chunk.forEach(this::upsert)); // one transaction per chunk
}
}
}
Mark it complete to track your progress through Spring Boot 4.