@Transactional in Depth: Propagation, Isolation, and Rollback

What @Transactional Does

@Transactional tells Spring to wrap the annotated method in a database transaction. Spring opens the transaction before the method starts and commits (or rolls back) when it ends. The annotation works via an AOP proxy — understanding this proxy model is essential for avoiding the most common @Transactional bugs.


The AOP Proxy Model

Spring creates a proxy around your bean. When code outside the bean calls a @Transactional method, the call goes through the proxy, which manages the transaction:

External caller → Proxy (opens tx) → Your @Transactional method → Proxy (commits/rolls back)

Self-invocation bypasses the proxy. If a method in the same class calls another @Transactional method, it calls directly (not through the proxy), so the transaction management is skipped:

@Service
public class OrderService {

    // Called from outside — proxy intercepts — transaction works ✓
    @Transactional
    public void placeOrder(OrderRequest request) {
        // ...
        sendConfirmation(request); // PROBLEM: direct call, no proxy involved
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendConfirmation(OrderRequest request) {
        // This runs in the SAME transaction as placeOrder, not a new one
        // The @Transactional annotation here is effectively ignored
    }
}

Fix: inject the bean into itself (Spring Boot supports this) or extract to a separate @Service:

@Service
public class OrderService {

    @Autowired
    private OrderService self; // Spring injects the proxy

    @Transactional
    public void placeOrder(OrderRequest request) {
        self.sendConfirmation(request); // goes through proxy ✓
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendConfirmation(OrderRequest request) { ... }
}

Propagation

Propagation defines what happens when a @Transactional method is called while a transaction is already active.

REQUIRED (default)

@Transactional(propagation = Propagation.REQUIRED)
public void doWork() { ... }
  • If a transaction exists → join it
  • If no transaction → create one

This is the right choice for 90% of cases. Two methods with REQUIRED that call each other share one transaction.

REQUIRES_NEW

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditLog(String message) { ... }
  • Always start a new transaction
  • Suspend the existing transaction if one is active
  • The new transaction commits/rolls back independently

Use for audit logging or outbox events where you want the log committed even if the outer transaction rolls back.

@Service
@Transactional
public class OrderService {

    private final AuditService auditService; // has REQUIRES_NEW

    public void placeOrder(Order order) {
        saveOrder(order);
        auditService.log("order placed: " + order.getId()); // commits independently
        if (someConditionFails()) {
            throw new RuntimeException(); // outer tx rolls back, audit log survives
        }
    }
}

SUPPORTS

@Transactional(propagation = Propagation.SUPPORTS)
public Product findProduct(Long id) { ... }
  • If a transaction exists → join it
  • If no transaction → run without one

Use for read methods that can work with or without a transaction. The persistence context is active when called within a transaction (lazy loading works), but the method doesn’t demand one.

NOT_SUPPORTED

@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void sendEmail(String to, String body) { ... }
  • Suspend the current transaction (if any)
  • Run without a transaction

Use for operations that must not run inside a transaction (slow I/O like email/HTTP calls that would hold a database connection open unnecessarily).

MANDATORY

@Transactional(propagation = Propagation.MANDATORY)
public void mustRunInTx() { ... }
  • If a transaction exists → join it
  • If no transaction → throw IllegalTransactionStateException

Use to assert that callers must provide a transaction. Documents an architectural contract.

NEVER

@Transactional(propagation = Propagation.NEVER)
public void mustNotRunInTx() { ... }
  • If a transaction exists → throw IllegalTransactionStateException
  • If no transaction → proceed normally

Use to assert that the method must not be called within a transaction.

NESTED

@Transactional(propagation = Propagation.NESTED)
public void nestedOperation() { ... }
  • If a transaction exists → create a savepoint; run as a nested transaction
  • If no transaction → behaves like REQUIRED

If the nested transaction rolls back, it rolls back only to the savepoint — the outer transaction can continue. Requires JDBC savepoint support (MySQL InnoDB supports this).


Propagation Quick Reference

PropagationExisting txNo tx
REQUIREDJoinCreate
REQUIRES_NEWSuspend + create newCreate
SUPPORTSJoinRun without
NOT_SUPPORTEDSuspendRun without
MANDATORYJoinException
NEVERExceptionRun without
NESTEDSavepointCreate

Isolation Levels

Isolation controls what data a transaction can see when other transactions are running concurrently. Higher isolation = fewer concurrency anomalies = more locking = lower throughput.

Concurrency Anomalies

AnomalyDescription
Dirty readRead uncommitted data from another transaction
Non-repeatable readSame row read twice gives different values
Phantom readSame query run twice returns different rows

Isolation Levels

@Transactional(isolation = Isolation.READ_COMMITTED)
public void doWork() { ... }
LevelDirty readNon-repeatable readPhantom read
READ_UNCOMMITTEDPossiblePossiblePossible
READ_COMMITTEDPreventedPossiblePossible
REPEATABLE_READPreventedPreventedPossible
SERIALIZABLEPreventedPreventedPrevented
DEFAULTUses DB default

MySQL InnoDB defaults to REPEATABLE_READ.

READ_COMMITTED

@Transactional(isolation = Isolation.READ_COMMITTED)
public ReportData generateReport() {
    // Each query sees the latest committed data
    // Two reads of the same row may return different values
    // Good for reporting where stale data is acceptable
}

The most common choice for OLTP applications. Each query snapshot is fresh. PostgreSQL default.

REPEATABLE_READ

@Transactional(isolation = Isolation.REPEATABLE_READ)
public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
    // Reading the same row twice gives the same result
    // Phantom rows (new rows inserted by other tx) are still possible (on some DBs)
}

Good for financial operations where consistency within the transaction is important. MySQL InnoDB default.

SERIALIZABLE

@Transactional(isolation = Isolation.SERIALIZABLE)
public void criticalInventoryUpdate() {
    // Complete isolation — as if transactions ran one by one
    // Highest consistency, lowest throughput
}

Use only when correctness absolutely requires it. Causes significant lock contention.


Rollback Rules

By default, Spring rolls back on unchecked exceptions (RuntimeException and Error) and commits on checked exceptions.

@Transactional
public void riskyOperation() throws IOException {
    // RuntimeException → ROLLBACK ✓ (default)
    // IOException (checked) → COMMIT (default — surprising!)
}

rollbackFor

@Transactional(rollbackFor = IOException.class)
public void riskyOperation() throws IOException {
    // Now IOException also triggers rollback
}

@Transactional(rollbackFor = Exception.class)
public void paranoidOperation() throws Exception {
    // Any exception triggers rollback
}

noRollbackFor

@Transactional(noRollbackFor = OptimisticLockException.class)
public void handleConcurrentUpdate() {
    // OptimisticLockException (RuntimeException) is caught and handled
    // Transaction commits rather than rolling back
}

Best Practice

Mark business exceptions that should trigger rollback:

@Transactional(rollbackFor = BusinessException.class)
public void placeOrder(OrderRequest request) {
    if (!inventoryService.isAvailable(request.getProductId())) {
        throw new InsufficientStockException("Out of stock"); // BusinessException subclass
    }
    // ...
}

readOnly = true

@Transactional(readOnly = true)
public List<Product> findActiveProducts() {
    return productRepository.findByActiveTrue();
}

readOnly = true tells Spring and the database driver that this transaction will not modify data. Benefits:

  1. Hibernate skips dirty checking — it won’t scan entities for changes at flush time. For large reads, this is a meaningful performance saving.
  2. Database optimization — MySQL can route read-only transactions to a read replica (with appropriate routing config).
  3. Documentation — makes intent explicit.

Always use readOnly = true on service methods that only read data.

Class-level + method-level pattern:

@Service
@Transactional(readOnly = true)  // default for all methods
public class ProductService {

    public List<Product> findAll() { ... }     // read-only ✓
    public Product findById(Long id) { ... }   // read-only ✓

    @Transactional  // override for writes (readOnly = false)
    public Product save(Product product) { ... }

    @Transactional
    public void delete(Long id) { ... }
}

timeout

@Transactional(timeout = 30) // seconds
public void slowOperation() {
    // Throws TransactionTimedOutException if not done in 30s
}

Use to prevent runaway transactions from holding database connections indefinitely.


Complete Service Example

@Service
@Transactional(readOnly = true)
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final AuditService auditService;

    // Read-only — inherits class-level @Transactional(readOnly=true)
    public Page<Order> findByCustomer(Long customerId, Pageable pageable) {
        return orderRepository.findByCustomerId(customerId, pageable);
    }

    // Write — override with default @Transactional
    @Transactional(rollbackFor = InsufficientStockException.class)
    public Order placeOrder(OrderRequest request) {
        Product product = productRepository.findById(request.getProductId())
            .orElseThrow(() -> new EntityNotFoundException("Product not found"));

        if (product.getStockQuantity() < request.getQuantity()) {
            throw new InsufficientStockException("Not enough stock");
        }

        product.setStockQuantity(product.getStockQuantity() - request.getQuantity());

        Order order = new Order();
        order.setCustomerId(request.getCustomerId());
        order.setProduct(product);
        order.setQuantity(request.getQuantity());
        order.setStatus(OrderStatus.PENDING);

        Order saved = orderRepository.save(order);

        // Audit in a separate transaction — commits even if outer tx rolls back
        auditService.logOrderPlaced(saved.getId()); // REQUIRES_NEW

        return saved;
    }

    @Transactional
    public void cancelOrder(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new EntityNotFoundException("Order not found"));

        if (order.getStatus() == OrderStatus.SHIPPED) {
            throw new IllegalStateException("Cannot cancel shipped order");
        }

        order.setStatus(OrderStatus.CANCELLED);
        // product stock restored via domain logic...
    }
}

Summary

  • @Transactional uses an AOP proxy — self-invocation bypasses it. Split into separate beans if propagation must apply.
  • Propagation: REQUIRED (default) for most operations; REQUIRES_NEW for audit/outbox; MANDATORY to document contract.
  • Isolation: use READ_COMMITTED for general OLTP; REPEATABLE_READ (MySQL default) for financial operations; SERIALIZABLE only when unavoidable.
  • Rollback: rolls back on RuntimeException by default; use rollbackFor to include checked exceptions.
  • readOnly = true: always use on read-only service methods — skips dirty checking, enables DB read replica routing.
  • Class-level @Transactional(readOnly = true) with method-level override for writes is the cleanest pattern.

Next: Article 23 covers transaction boundaries and common pitfalls — LazyInitializationException, the open-session-in-view anti-pattern, and transaction-less repository calls.