Home/Learn/Spring Boot/Transactions

Transactions

Intermediate
Data & JPA

@Transactional wraps a method in a database transaction — all operations commit together or roll back together. Understanding propagation and isolation levels prevents subtle data corruption bugs in production.

Overview

Spring's transaction management is declarative — @Transactional on a service method causes a proxy to begin a transaction before the method and commit or roll back after. By default, Spring rolls back on unchecked exceptions (RuntimeException and Error) but NOT on checked exceptions. Propagation controls what happens when a @Transactional method calls another — REQUIRED (default) joins the existing transaction, REQUIRES_NEW suspends it and starts a fresh one. Isolation level controls what concurrent transactions can see.

@Transactional Basics and Rollback Rules

Apply @Transactional at the service layer, not the repository or controller. The proxy cannot intercept internal method calls (self-invocation), so calling a @Transactional method from another method in the same class does NOT start a transaction.

Java — @Transactional basics, rollback, self-invocation pitfall
@Service
@Transactional  // class-level default: all public methods are transactional
public class OrderService {

    // Inherits class-level @Transactional
    public Order createOrder(CreateOrderRequest req) {
        Order order = orderRepository.save(new Order(req));
        inventoryService.reserve(req.items()); // same transaction
        paymentService.charge(req.paymentMethod(), order.total()); // same transaction
        // If paymentService.charge() throws → entire transaction rolls back
        return order;
    }

    // Override: read-only hint (enables flush-mode optimization)
    @Transactional(readOnly = true)
    public Order findById(String id) {
        return orderRepository.findById(id).orElseThrow();
    }

    // Roll back on checked exception too
    @Transactional(rollbackFor = PaymentException.class)
    public void processRefund(String orderId) throws PaymentException {
        // ...
    }

    // ❌ Self-invocation — proxy is bypassed, NO transaction started
    public void methodA() {
        this.methodB(); // calls target, not proxy — @Transactional ignored!
    }

    @Transactional
    public void methodB() { /* ... */ }
}

Propagation Levels

Propagation defines how a transaction behaves when one @Transactional method calls another. REQUIRED (default) is safe for most cases. REQUIRES_NEW is critical when you need an operation to commit independently — like audit logging that must persist even if the outer transaction rolls back.

Java — Propagation.REQUIRES_NEW for independent commits
@Service
public class AuditService {

    // REQUIRES_NEW — suspends outer transaction, commits independently
    // Use for: audit logs, notifications, anything that must NOT rollback
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAction(String userId, String action) {
        auditRepository.save(new AuditLog(userId, action, Instant.now()));
        // This ALWAYS commits, even if the caller's transaction rolls back
    }
}

@Service
public class PaymentService {

    private final AuditService auditService;

    @Transactional
    public void charge(String userId, BigDecimal amount) {
        // outer transaction T1
        processCharge(userId, amount);
        auditService.logAction(userId, "PAYMENT_CHARGED"); // T2 — new transaction
        if (someCondition) {
            throw new RuntimeException(); // T1 rolls back, T2 already committed
        }
    }
}

// Propagation summary:
// REQUIRED       — join existing; create new if none (default)
// REQUIRES_NEW   — always new transaction; suspend existing
// SUPPORTS       — join if exists; non-transactional if none
// NOT_SUPPORTED  — run non-transactionally; suspend existing
// MANDATORY      — must have existing transaction; throw if none
// NEVER          — must NOT have transaction; throw if one exists
// NESTED         — nested savepoint within existing (rollback to savepoint)

Isolation Levels

Isolation controls what dirty data from concurrent transactions a transaction can read. Higher isolation = fewer anomalies but more locking and lower throughput. Choose the minimum isolation level that prevents the anomalies you care about.

Java — isolation levels and when to use them
// Isolation levels and anomalies they prevent:
// READ_UNCOMMITTED — can read dirty (uncommitted) data. No locks. Fastest.
// READ_COMMITTED   — can't read dirty data. Default for PostgreSQL/Oracle.
// REPEATABLE_READ  — repeated reads of same row return same value. Default for MySQL.
// SERIALIZABLE     — full isolation. Slowest. Transactions execute as if sequential.

// Anomalies:
// Dirty Read       — reading uncommitted data from another transaction
// Non-repeatable  — same row reads different values within one transaction
// Phantom Read     — range query returns different rows on repeated reads

@Transactional(isolation = Isolation.REPEATABLE_READ)
public OrderSummary generateReport(String customerId) {
    // First read — 5 orders
    List<Order> orders = orderRepository.findByCustomerId(customerId);
    BigDecimal total = orders.stream().map(Order::getTotal).reduce(ZERO, BigDecimal::add);
    // ... heavy processing ...
    // Second read of same data guaranteed to return same rows (no phantom)
    return new OrderSummary(orders.size(), total);
}

// For most web apps: READ_COMMITTED is sufficient
// Use SERIALIZABLE only for financial ledger / inventory reservation
// Optimistic locking (@Version) is often better than higher isolation

Key Points to Remember

  • 1@Transactional on a class applies to all public methods — methods can override with their own annotation.
  • 2Spring rolls back on RuntimeException/Error by default — use rollbackFor for checked exceptions.
  • 3Self-invocation bypasses the proxy — internal @Transactional calls from the same class have no effect.
  • 4readOnly = true is an optimization hint to the database — use it for all read-only service methods.
  • 5REQUIRES_NEW suspends the outer transaction and commits independently — essential for audit logs.
  • 6Prefer READ_COMMITTED isolation + optimistic locking (@Version) over high isolation levels in most web apps.

Interview Questions

Sign in to ask Aria
1

Why does @Transactional not work on private methods or self-invocation?

HardAmazon
2

What is the default rollback behaviour of @Transactional?

EasyTCS
3

What is the difference between REQUIRED and REQUIRES_NEW propagation?

MediumGoldman Sachs
4

What isolation level does PostgreSQL use by default and what anomalies does it prevent?

MediumThoughtWorks
5

How does optimistic locking with @Version differ from database-level isolation?

HardAtlassian

Ask Aria about Transactions

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…