Transaction Management Deep Dive
Transactions beyond the annotation: every propagation mode and when it applies, isolation levels and the anomalies they prevent, transaction-bound events, and why XA lost to sagas.
On this page
@Transactional looks like a single decision and is actually four: where the boundary sits, how it
composes with an existing transaction, what isolation it needs, and what rolls it back. Most
transaction bugs come from the second and fourth.
Key Takeaways
- Spring rolls back on unchecked exceptions only — checked ones commit unless declared.
- The annotation is proxy-based, so self-invocation does nothing at all.
REQUIRES_NEWtakes a second connection and can deadlock against its own caller.- Isolation is a trade between anomalies prevented and concurrency lost.
- Use
@TransactionalEventListener(AFTER_COMMIT)so side effects never fire for rolled-back work.
Propagation
| Mode | With an existing transaction | Without one |
|---|---|---|
REQUIRED (default) | Join it | Create one |
REQUIRES_NEW | Suspend it, create a new one | Create one |
SUPPORTS | Join it | Run without |
NOT_SUPPORTED | Suspend it, run without | Run without |
MANDATORY | Join it | Throw |
NEVER | Throw | Run without |
NESTED | Savepoint within it | Create one |
@Service
public class OrderService {
@Transactional
public void place(PlaceOrderCommand command) {
Order order = orders.save(Order.place(command));
// Records the attempt even if the order later fails and rolls back.
auditService.recordAttempt(order.id());
paymentService.charge(order); // may throw
}
}
@Service
public class AuditService {
// A separate transaction on a separate connection. Commits independently
// of the caller — which is the point, and also the deadlock risk if both
// touch the same rows.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordAttempt(OrderId orderId) {
auditRepository.save(new AuditEntry(orderId, Instant.now()));
}
}
@Component
public class OutboxPublisher {
// MANDATORY documents and enforces that this must be part of the caller's
// transaction. Calling it outside one is a programming error, and this
// turns that into an immediate exception rather than a lost event.
@Transactional(propagation = Propagation.MANDATORY)
public void publish(DomainEvent event) {
outboxRepository.save(OutboxRecord.from(event));
}
}MANDATORY is underused and genuinely valuable. An outbox write that silently succeeds outside a
transaction breaks the atomicity guarantee the pattern exists to provide — and nothing would tell you
until events start appearing for operations that rolled back.
The rollback rule
@Transactional
public void willNotRollBack() throws BusinessException {
repository.save(entity);
// Checked exception: Spring COMMITS. This surprises people constantly.
throw new BusinessException("failed");
}
@Transactional(rollbackFor = Exception.class)
public void willRollBack() throws BusinessException {
repository.save(entity);
throw new BusinessException("failed");
}
@Transactional(noRollbackFor = InsufficientStockException.class)
public void keepsPartialWork() {
repository.save(auditEntry);
throw new InsufficientStockException(); // commits the audit entry
}The default — roll back on RuntimeException and Error, commit on checked exceptions — is inherited
from EJB and rarely what anyone wants. Either make domain exceptions unchecked, which is the modern
convention, or declare rollbackFor explicitly.
The other silent failure is self-invocation. A @Transactional method called from another method in
the same class bypasses the proxy entirely and runs with no transaction — no warning, no error, just a
missing rollback discovered much later.
Isolation
| Level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
READ_UNCOMMITTED | Possible | Possible | Possible |
READ_COMMITTED | No | Possible | Possible |
REPEATABLE_READ | No | No | Possible* |
SERIALIZABLE | No | No | No |
*MySQL InnoDB prevents phantoms at REPEATABLE_READ using next-key locking, which is stricter than
the standard requires.
In practice, raising isolation is rarely the right fix for a concurrency problem. READ_COMMITTED
plus optimistic locking handles the common case — two users editing the same record — with far better
throughput:
@Entity
public class Order {
@Version
private Long version; // incremented on every update
}
@Transactional
public void updateWithRetry(OrderId id, Consumer<Order> change) {
try {
Order order = orders.findById(id).orElseThrow();
change.accept(order);
} catch (OptimisticLockingFailureException ex) {
// Someone else wrote first. Reload and retry, or surface a 409.
throw new ConcurrentModificationException(id);
}
}For genuine contention on a specific row — decrementing stock, allocating a seat — pessimistic locking is clearer than raising isolation:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
@Query("select s from Stock s where s.sku = :sku")
Optional<Stock> findForUpdate(@Param("sku") String sku);Always set a lock timeout. Without one, a lock wait can block until the connection times out, and the symptom is a service that appears hung rather than one returning an error.
Transaction-bound events
@Service
public class OrderService {
@Transactional
public void place(PlaceOrderCommand command) {
Order order = orders.save(Order.place(command));
events.publishEvent(new OrderPlaced(order.id()));
}
}
@Component
public class OrderPlacedHandlers {
// Runs only if the transaction committed. An email for a rolled-back
// order is the classic bug this prevents.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendConfirmation(OrderPlaced event) {
mailer.sendOrderConfirmation(event.orderId());
}
// Cache eviction also belongs after commit, or a concurrent read
// repopulates the cache from the old, uncommitted state.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void evictCache(OrderPlaced event) {
cacheManager.getCache("orders").evict(event.orderId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void recordFailure(OrderPlaced event) {
metrics.counter("orders.failed").increment();
}
}This is the cleanest available answer to "do something after this succeeds". A plain
@EventListener fires synchronously inside the transaction, so the email goes out and then the
transaction rolls back.
One caveat: an AFTER_COMMIT handler runs outside any transaction, so a database write there needs
REQUIRES_NEW. And if that handler fails, the original transaction has already committed — which is
why durable side effects belong in an outbox rather than an event listener.
Distributed transactions
XA two-phase commit spans multiple resources atomically, and it lost for good reasons: locks are held across a network round trip, the coordinator is a single point of failure whose crash leaves participants blocked, and most modern infrastructure — Kafka, cloud databases, HTTP APIs — has no XA support at all.
The replacement is the saga: a sequence of local transactions, each with a compensating action, tied together by events. You give up atomicity and get availability, which for a distributed system is the right trade.
Within a single service, though, use a real transaction. Reaching for a saga where one database transaction would do is complexity with no benefit.
What to take away
Know that checked exceptions commit by default and that self-invocation bypasses the proxy — those two
facts explain most transaction bugs. Keep isolation at READ_COMMITTED and reach for optimistic
locking rather than raising it. Use MANDATORY to enforce that outbox writes are transactional, and
AFTER_COMMIT events for side effects that must not fire on rollback.
Frequently Asked Questions
Why does my rollback not happen?
When should I use REQUIRES_NEW?
Is @Transactional(readOnly = true) worth adding?
Related tutorials
- Domain-Driven Design in PracticeDDD applied rather than described: choosing aggregate boundaries, value objects that enforce invariants, repositories, hexagonal architecture, and running an event storming session.
- API Security & the OWASP API Top 10The API-specific vulnerability classes and their Spring fixes: broken object-level authorization, mass assignment, unrestricted consumption, SSRF, and API inventory management.
- CQRS & Event SourcingSeparating reads from writes: CQRS without event sourcing, event stores and aggregate replay, building projections, snapshots, and an honest account of when not to use either.
- gRPC in Java MicroservicesgRPC for internal service calls: Protocol Buffers and schema evolution, the four RPC types, deadlines and interceptors, Spring Boot integration, and an honest comparison with REST.