Transactions: @Transactional, Propagation, and Isolation Levels

A transaction ensures that a group of operations either all succeed or all fail — no partial state. Spring’s @Transactional makes this simple to use, but the underlying mechanics matter when things go wrong.

How @Transactional Works

@Transactional is implemented via AOP (Aspect-Oriented Programming). Spring wraps your bean in a proxy:

Client calls orderService.create()
          │
          ▼
Spring AOP proxy intercepts the call
          │
          ▼
BEGIN TRANSACTION
          │
          ▼
Your actual method body executes
   (all DB operations share one connection and transaction)
          │
          ├─ No exception → COMMIT
          │
          └─ RuntimeException thrown → ROLLBACK
          │
          ▼
Client receives result (or exception)

This means @Transactional only works when:

  1. The method is called through the Spring proxy (not from within the same class)
  2. The method is public (Spring’s default proxy cannot intercept private/protected methods)

Basic Usage

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;

    @Transactional
    public Order createOrder(CreateOrderRequest request) {
        // All three operations run in one transaction
        Order order = orderRepository.save(Order.from(request));
        inventoryService.reserve(order.getItems());    // may throw
        paymentService.authorize(order);               // may throw

        // If inventoryService or paymentService throws → ROLLBACK
        // The order INSERT is also rolled back
        return order;
    }
}

Rollback Rules

By default, @Transactional rolls back on unchecked exceptions (subclasses of RuntimeException) and Errors. It does NOT roll back on checked exceptions.

@Transactional
public void processOrder(UUID id) throws InventoryException {
    Order order = orderRepository.findById(id).orElseThrow();
    order.confirm();

    try {
        inventoryService.reserve(order);
    } catch (InventoryException e) {
        // Checked exception — transaction is NOT rolled back by default
        // order.confirm() remains committed!
        throw e;
    }
}

Fix with explicit rollback rules:

// Rollback on checked exception too
@Transactional(rollbackFor = InventoryException.class)
public void processOrder(UUID id) throws InventoryException { ... }

// Rollback on any exception
@Transactional(rollbackFor = Exception.class)
public void processOrder(UUID id) throws Exception { ... }

// Don't rollback on a specific runtime exception
@Transactional(noRollbackFor = OptimisticLockException.class)
public void processOrder(UUID id) { ... }

Propagation — What Happens When Transactions Are Nested

Propagation controls what happens when a @Transactional method calls another @Transactional method.

REQUIRED (default) — Join existing or create new

@Service
public class OrderService {

    @Transactional   // starts transaction T1
    public void createOrder(CreateOrderRequest req) {
        Order order = orderRepository.save(Order.from(req));
        emailService.sendConfirmation(order);  // runs in T1 (joined)
    }
}

@Service
public class EmailService {

    @Transactional   // joins T1 if exists, creates new if not
    public void sendConfirmation(Order order) {
        emailLogRepository.save(new EmailLog(order));
        // This is part of T1 — if OrderService rolls back, this rolls back too
    }
}

REQUIRES_NEW — Always create a new, independent transaction

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAuditEvent(AuditEvent event) {
        auditRepository.save(event);
        // Runs in its own transaction — always committed regardless of outer transaction
    }
}

@Service
public class OrderService {

    @Transactional
    public void processOrder(UUID id) {
        Order order = orderRepository.findById(id).orElseThrow();

        try {
            order.process();
        } finally {
            // Always audit, even if processing fails
            auditService.logAuditEvent(AuditEvent.forOrder(order));
            // REQUIRES_NEW: audit is committed even if the outer transaction rolls back
        }
    }
}

NOT_SUPPORTED — Run without a transaction

@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void sendNotificationEmail(UUID orderId) {
    // Suspends the outer transaction
    // Runs without a transaction — no rollback if this fails
    // Good for: long-running operations that shouldn't hold a DB connection
}

NEVER — Fail if a transaction exists

@Transactional(propagation = Propagation.NEVER)
public void exportReport() {
    // Throws if called from within a transaction
    // Good for: bulk operations that shouldn't be transactional
}

Other propagation types

PropagationBehavior
REQUIREDJoin existing, or create new
REQUIRES_NEWAlways create new, suspend existing
SUPPORTSJoin existing if present, run without if not
NOT_SUPPORTEDSuspend existing, run without transaction
MANDATORYJoin existing, throw if none exists
NEVERThrow if a transaction exists
NESTEDCreate a savepoint in the existing transaction

The Self-Invocation Trap

This is the most common @Transactional mistake:

@Service
public class OrderService {

    @Transactional
    public void createOrder(CreateOrderRequest req) {
        Order order = orderRepository.save(Order.from(req));
        // BAD: calling another @Transactional method in the same class
        // This goes directly to the method — bypasses the proxy!
        sendConfirmation(order);  // @Transactional annotation is IGNORED
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendConfirmation(Order order) {
        // This runs in T1, not a new transaction — the annotation is ignored
    }
}

Fix 1: Move to a separate service class (cleanest):

@Service
public class OrderConfirmationService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void send(Order order) { ... }
}

Fix 2: Self-inject the proxy (ugly, avoid):

@Service
public class OrderService {

    @Autowired @Lazy
    private OrderService self;  // injected proxy, not 'this'

    public void createOrder(CreateOrderRequest req) {
        Order order = orderRepository.save(Order.from(req));
        self.sendConfirmation(order);  // goes through proxy — annotation works
    }
}

Isolation Levels

Isolation controls what concurrent transactions can see of each other’s changes.

Isolation LevelDirty ReadNon-Repeatable ReadPhantom Read
READ_UNCOMMITTEDPossiblePossiblePossible
READ_COMMITTED (typical default)PreventedPossiblePossible
REPEATABLE_READPreventedPreventedPossible
SERIALIZABLEPreventedPreventedPrevented

Dirty Read: Reading uncommitted data from another transaction (that might roll back)
Non-Repeatable Read: Re-reading the same row gives different results (updated by another transaction)
Phantom Read: Re-executing a query gives different rows (inserted/deleted by another transaction)

// Use REPEATABLE_READ to prevent re-read inconsistency
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processHighValueOrder(UUID id) {
    Order order = orderRepository.findById(id).orElseThrow();
    // other transaction updates the order here...
    BigDecimal total = order.getTotalAmount(); // same value as the first read
}

// Use SERIALIZABLE for strict consistency (highest isolation, highest contention)
@Transactional(isolation = Isolation.SERIALIZABLE)
public void allocateInventory(UUID productId, int quantity) {
    // Other transactions wait — prevents phantom reads of inventory records
}

// READ_COMMITTED is the default for most apps (PostgreSQL default)
@Transactional(isolation = Isolation.READ_COMMITTED)
public void readOrderReport() { ... }

Recommendation: Leave isolation at the default DEFAULT (database’s default — usually READ_COMMITTED for PostgreSQL). Only raise isolation when you have a specific concurrency problem.

Read-Only Transactions

For queries, use readOnly = true:

@Service
@Transactional(readOnly = true)  // default for the class
public class OrderQueryService {

    public Order findById(UUID id) {
        return orderRepository.findById(id).orElseThrow();
    }

    public Page<Order> findAll(Pageable pageable) {
        return orderRepository.findAll(pageable);
    }
}

Benefits of readOnly = true:

  • Hibernate skips dirty checking — no snapshot comparison at transaction end
  • Some databases route read-only transactions to read replicas
  • Makes intent clear — the compiler won’t catch mutations, but it signals intent

Timeout

Fail a transaction that runs too long:

@Transactional(timeout = 30)   // 30 seconds
public void processLargeExport() {
    // throws TransactionTimedOutException after 30s
}

Practical Transaction Boundaries

Rule: Place @Transactional at the service layer, never at the repository or controller layer.

Controller           — HTTP, no @Transactional
     │ calls
     ▼
Service (@Transactional)  — one transaction per use case
     │ calls
     ▼
Repository           — inherits the transaction from service

Why not at the repository? Repositories are too granular — a use case often spans multiple repositories. If each repository method runs in its own transaction, you lose atomicity.

Why not at the controller? Controllers deal with HTTP — too high-level. Transaction scope should be as small as possible.

Good pattern:

@RestController
public class OrderController {  // no @Transactional

    private final OrderService orderService;

    @PostMapping("/api/orders")
    public ResponseEntity<OrderResponse> createOrder(@RequestBody @Valid CreateOrderRequest req) {
        Order order = orderService.createOrder(req);  // transaction is inside here
        return ResponseEntity.created(URI.create("/api/orders/" + order.id())).body(OrderResponse.from(order));
    }
}

@Service
public class OrderService {  // @Transactional here

    @Transactional
    public Order createOrder(CreateOrderRequest req) {
        Order order = orderRepository.save(Order.from(req));
        inventoryService.reserve(order);
        return order;
    }
}

Programmatic Transactions

For fine-grained control or when AOP-based transactions don’t work:

@Service
@RequiredArgsConstructor
public class BatchOrderService {

    private final TransactionTemplate transactionTemplate;
    private final OrderRepository orderRepository;

    public void importOrders(List<OrderData> data) {
        for (OrderData item : data) {
            try {
                transactionTemplate.execute(status -> {
                    Order order = orderRepository.save(Order.from(item));
                    // ... other operations
                    return order;
                });
            } catch (Exception e) {
                // Log and continue — each order in its own transaction
                log.error("Failed to import order {}", item.getId(), e);
            }
        }
    }
}

TransactionTemplate is useful when you need a transaction around code that can’t be in a @Transactional method (e.g., inside a lambda, or in a thread pool task).

What You’ve Learned

  • @Transactional uses AOP proxying — only works on public methods called through the Spring proxy
  • Default rollback on RuntimeException; use rollbackFor for checked exceptions
  • Propagation.REQUIRED (default) joins existing; REQUIRES_NEW creates independent transaction
  • Self-invocation bypasses the proxy — move to a separate class to fix
  • Isolation levels control concurrent transaction visibility — leave at DB default unless you have a specific problem
  • readOnly = true skips dirty checking and enables DB routing to replicas
  • Place @Transactional at the service layer — one transaction per business use case

Next: Article 21 — Database Migrations with Flyway — version-controlled schema evolution without manual SQL.