Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate6 min readUpdated
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_NEW takes 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

ModeWith an existing transactionWithout one
REQUIRED (default)Join itCreate one
REQUIRES_NEWSuspend it, create a new oneCreate one
SUPPORTSJoin itRun without
NOT_SUPPORTEDSuspend it, run withoutRun without
MANDATORYJoin itThrow
NEVERThrowRun without
NESTEDSavepoint within itCreate one
Propagation.java
@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

RollbackRules.java
@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

Higher isolation prevents more anomalies and permits less concurrency. READ_COMMITTED plus optimistic locking covers most real needs.
LevelDirty readNon-repeatable readPhantom read
READ_UNCOMMITTEDPossiblePossiblePossible
READ_COMMITTEDNoPossiblePossible
REPEATABLE_READNoNoPossible*
SERIALIZABLENoNoNo

*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:

OptimisticLocking.java
@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:

PessimisticLocking.java
@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

TransactionalEvents.java
@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?
Three usual causes. Spring only rolls back on unchecked exceptions by default — a checked exception commits unless you add rollbackFor. The method was called from inside the same class, so the proxy was bypassed. Or the exception was caught and swallowed somewhere in between.
When should I use REQUIRES_NEW?
When work must persist even if the caller rolls back — an audit record, a failed-attempt counter, an outbox entry for a compensating action. It suspends the outer transaction and takes a second connection, so it can deadlock against the transaction that called it if both touch the same rows.
Is @Transactional(readOnly = true) worth adding?
Yes. Hibernate skips dirty checking, which saves memory and CPU on large result sets, and the flag can route the connection to a read replica. It also documents intent, and some drivers optimise the connection. It costs one attribute.

Related tutorials