Transactions, Caching & Performance

Transaction Propagation

18 min Lesson 2 of 13

Transaction Propagation

When one Spring-managed service method calls another, both annotated with @Transactional, Spring must decide: should the inner method join the existing transaction, suspend it and open a fresh one, or perhaps run without any transaction at all? This decision is controlled by the propagation attribute of @Transactional — one of the most practical and frequently misunderstood knobs in the entire Spring/JPA stack.

Understanding propagation lets you reason about database consistency, connection-pool usage, and subtle rollback behaviour that would otherwise produce mysterious bugs in production.

How Spring Implements Propagation

Spring wraps every @Transactional bean in a proxy. When a caller invokes a transactional method, the proxy intercepts the call and asks the PlatformTransactionManager to provide or suspend a transaction according to the configured propagation. The actual JDBC connection is bound to the current thread via TransactionSynchronizationManager. Once the annotated method returns (or throws), the proxy either commits or rolls back and releases the connection.

Self-invocation bypasses the proxy. If method A and method B are in the same bean and A calls B directly (this.B()), Spring's proxy never intercepts the call and B's propagation setting is silently ignored. Always inject a reference to your own bean when you need intra-bean propagation semantics.

The Seven Propagation Levels

Spring defines propagation as the enum org.springframework.transaction.annotation.Propagation. Here are all seven values:

  • REQUIRED (default) — join an existing transaction; start one if none exists.
  • REQUIRES_NEW — always suspend any existing transaction and open a brand-new, independent one.
  • NESTED — run inside a savepoint within the existing transaction; roll back only to the savepoint on failure, not the whole outer transaction.
  • SUPPORTS — join if a transaction exists; run non-transactionally if not.
  • NOT_SUPPORTED — always suspend any existing transaction and execute without one.
  • MANDATORY — must join an existing transaction; throw IllegalTransactionStateException if there is none.
  • NEVER — must run without a transaction; throw if one exists.

REQUIRED — The Workhorse

REQUIRED is the default and the right choice for the vast majority of service methods. A single database connection is shared across the entire call stack, which means all writes participate in one atomic unit.

import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class OrderService { private final InventoryService inventoryService; private final OrderRepository orderRepository; // constructor injection omitted for brevity @Transactional // REQUIRED is the default public void placeOrder(Order order) { orderRepository.save(order); // joins (or starts) the transaction inventoryService.reduceStock(order); // InventoryService is also REQUIRED — // it joins the SAME transaction } } @Service public class InventoryService { private final StockRepository stockRepository; @Transactional // joins the transaction started by OrderService public void reduceStock(Order order) { // runs inside the same connection / unit of work stockRepository.decrementQuantity(order.getProductId(), order.getQuantity()); } }

Key behaviour: if reduceStock throws an unchecked exception, the entire unit of work — including the save already called — is rolled back. That is usually exactly what you want.

REQUIRES_NEW — An Independent Transaction

REQUIRES_NEW tells Spring to suspend the caller's transaction, open a second independent connection, commit or roll back that connection, and then resume the original transaction. The two transactions are completely separate database sessions.

import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.stereotype.Service; @Service public class AuditService { private final AuditLogRepository auditLogRepository; @Transactional(propagation = Propagation.REQUIRES_NEW) public void log(String action, Long entityId) { AuditEntry entry = new AuditEntry(action, entityId); auditLogRepository.save(entry); // This commit happens even if the OUTER transaction later rolls back. } }
Classic use case — audit logging. You want the audit record to survive even if the business operation fails. With REQUIRES_NEW, the audit row is committed independently, so you never lose the trace of what was attempted.
Connection pool pressure. REQUIRES_NEW holds two open JDBC connections simultaneously — one suspended, one active. With a small pool (e.g. HikariCP default 10), deeply nested or high-concurrency use of REQUIRES_NEW can exhaust the pool and cause thread starvation. Use it deliberately, not as a default.

NESTED — Savepoint-Based Partial Rollback

NESTED uses a JDBC savepoint to carve a sub-unit out of the existing transaction. If the inner method fails, Spring rolls back only to the savepoint — the outer transaction is still alive and can choose to commit or continue.

import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.stereotype.Service; @Service public class NotificationService { private final NotificationRepository notificationRepository; @Transactional(propagation = Propagation.NESTED) public void sendEmailNotification(Long userId, String message) { // If this fails, only the notification INSERT is rolled back — // the outer order transaction keeps its data intact. notificationRepository.save(new Notification(userId, message)); emailGateway.send(userId, message); // might throw } } @Service public class OrderService { @Transactional public void finaliseOrder(Order order) { orderRepository.save(order); try { notificationService.sendEmailNotification(order.getUserId(), "Order confirmed"); } catch (RuntimeException ex) { // Notification failed, but order is still committed log.warn("Notification delivery failed for order {}", order.getId()); } // execution continues; the outer transaction will commit } }
NESTED vs REQUIRES_NEW: Both allow inner failure isolation, but they differ fundamentally. With REQUIRES_NEW the inner work is already committed before the outer transaction finishes — it cannot be rolled back by the outer transaction. With NESTED, the inner work is uncommitted; if the outer transaction later rolls back, the inner savepoint is rolled back too.

SUPPORTS, NOT_SUPPORTED, MANDATORY, NEVER

These four are used less frequently but have clear purposes:

  • SUPPORTS — read-heavy helper methods that work with or without a transaction. Useful for queries you expose in both transactional and non-transactional contexts.
  • NOT_SUPPORTED — useful for long-running, read-only batch exports where holding a transaction open wastes a connection. Spring suspends any caller transaction and runs the method plain.
  • MANDATORY — enforces that a method must always be called from within an active transaction. Great as a guard in lower-layer repository wrappers that should never be called without context.
  • NEVER — enforces transactional cleanliness in methods that must not touch the database transactionally (e.g. async or background tasks launched via a scheduler).

Choosing the Right Propagation

A practical decision tree:

  1. Is the operation part of the same atomic unit as the caller? → REQUIRED (default).
  2. Must the operation commit regardless of what the caller does (e.g. audit, metrics)? → REQUIRES_NEW.
  3. Should partial failure be isolated without releasing the caller's connection? → NESTED.
  4. Is the method purely a read and you do not care about transactions? → SUPPORTS or add readOnly = true with REQUIRED.
  5. Does calling without a transaction indicate a programming error? → MANDATORY.

Summary

Transaction propagation is the mechanism Spring uses to coordinate nested transactional calls. REQUIRED (default) shares a transaction; REQUIRES_NEW creates an independent, immediately committed unit; NESTED creates a savepoint for partial rollback within the outer transaction. Understanding these three covers the majority of real-world scenarios. In the next lesson you will add another dimension — isolation levels — which control what uncommitted data concurrent transactions can see.