💳 Section 11 · Question #13

What is Transaction Propagation in Spring

In Spring Framework, Transaction Propagation is an attribute of the @Transactional annotation that governs how transaction boundaries behave when one transactional method invoke...


🟢 Junior Level

In Spring Framework, Transaction Propagation is an attribute of the @Transactional annotation that governs how transaction boundaries behave when one transactional method invokes another transactional method.

The Core Concept in 30 Seconds

When a method marked with @Transactional calls another @Transactional method, Spring’s transaction infrastructure must decide:

  • Should the inner method join the existing active transaction?
  • Should the current transaction be suspended and a brand-new, independent physical transaction opened on a separate database connection?
  • Should the code execute non-transactionally?
  • Should an exception be thrown because a transaction is missing (or conversely, because one is already present)?

This behavior is configured via the propagation attribute:

@Transactional(propagation = Propagation.REQUIRED) // Default setting
public void someBusinessMethod() { ... }

The 7 Propagation Types in Spring

Type If Active Transaction Exists If No Transaction Exists Typical Production Scenario
REQUIRED (Default) Joins current transaction Starts a new transaction 90% of standard business domain operations
REQUIRES_NEW Suspends current; starts new physical transaction Starts a new transaction Independent audit logs, billing attempts, notification tracking
NESTED Creates a JDBC Savepoint within the current transaction Starts a new transaction Batch processing (rolling back failed batch items without failing the batch)
MANDATORY Joins current transaction Throws IllegalTransactionStateException Sub-operations that must strictly be orchestrated by a parent transaction
SUPPORTS Joins current transaction Executes non-transactionally Read-only operations optimized to run with or without a transaction
NOT_SUPPORTED Suspends current transaction Executes non-transactionally Long-running non-database I/O (REST calls, email dispatch, external RPC)
NEVER Throws IllegalTransactionStateException Executes non-transactionally Operations that must strictly avoid holding database transaction locks

Practical Example: Order Processing with Independent Audit Logging

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final AuditService auditService;

    public OrderService(OrderRepository orderRepository, AuditService auditService) {
        this.orderRepository = orderRepository;
        this.auditService = auditService;
    }

    @Transactional // REQUIRED by default: opens Transaction #1
    public void placeOrder(Order order) {
        orderRepository.save(order);

        // Invokes an independent method on a separate bean configured with REQUIRES_NEW
        auditService.logAction("Attempting order creation for order #" + order.getId());

        if (order.getTotalPrice() == null) {
            // Throwing RuntimeException triggers rollback of Transaction #1,
            // but the audit log from logAction remains safely COMMITTED in the database!
            throw new IllegalArgumentException("Order price must be specified!");
        }
    }
}
@Service
public class AuditService {

    private final AuditLogRepository auditLogRepository;

    public AuditService(AuditLogRepository auditLogRepository) {
        this.auditLogRepository = auditLogRepository;
    }

    // Suspends Transaction #1, opens Transaction #2 on a separate physical connection,
    // and commits independently of the caller's outcome
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAction(String message) {
        auditLogRepository.save(new AuditLog(message));
    }
}

🟡 Middle Level

Architectural Pipeline: Spring AOP, Proxy, and TransactionInterceptor

Spring manages declarative transactions via the AOP Proxy design pattern and TransactionInterceptor:

Client Call:
service.placeOrder()
        │
        ▼
[ CGLIB / Dynamic Proxy ]
        │
        ▼
[ TransactionInterceptor ]
  1. Inspects @Transactional(propagation = ...)
  2. Queries PlatformTransactionManager
  3. Inspects ThreadLocal resources in TransactionSynchronizationManager
  4. Decides: JOIN / SUSPEND / CREATE NEW / SAVEPOINT
        │
        ▼
[ Target Bean Method Execution ]
        │
        ▼
[ TransactionInterceptor ]
  5. On success: Commit (or no-op if participating in an outer transaction)
  6. On exception: Rollback / Rollback to Savepoint / Mark setRollbackOnly()

Detailed Mechanics of Each Propagation Type

1. Propagation.REQUIRED (Default)

Executes within a single logical and physical transaction context.

  • The Rollback-Only Trap: If an inner REQUIRED method throws an unchecked exception, Spring marks the underlying physical transaction as rollback-only. Even if the outer method catches the exception in a try-catch block, attempting to commit the outer method will inevitably fail with UnexpectedRollbackException.

2. Propagation.REQUIRES_NEW

Always starts a brand-new physical database transaction using an additional connection from the connection pool.

  • The outer transaction is temporarily suspended.
  • The active thread holds Connection #1 (in suspended state) and checks out Connection #2 from the pool.
  • The inner transaction commits or rolls back completely independently of the outer transaction’s fate.
  • Upon completion, Connection #2 is released back to the pool, and the outer transaction is resumed.

3. Propagation.NESTED

Creates a JDBC Savepoint within the same physical database connection.

  • Does not consume a second database connection.
  • Before executing the inner method, Spring calls connection.setSavepoint("...").
  • If the inner method throws an exception, Spring executes connection.rollback(savepoint). The outer transaction can catch the exception and proceed to commit its own work successfully.

4. Propagation.MANDATORY

Demands an existing transaction. If invoked without an active transaction, Spring immediately raises: IllegalTransactionStateException: No existing transaction found for transaction marked with propagation 'mandatory'.

5. Propagation.SUPPORTS

Adapts dynamically to the caller: if a transaction is already active, it participates; if no transaction exists, it executes non-transactionally in auto-commit mode.

6. Propagation.NOT_SUPPORTED

Suspends an active transaction for the duration of the method and executes the code non-transactionally. Recommended for long-running blocking operations (such as external HTTP/REST calls) to avoid holding database row locks and connection pool resources.

7. Propagation.NEVER

The inverse of MANDATORY: throws IllegalTransactionStateException if an active transaction is detected.


🔴 Senior Level

Transaction Engine Internals: TransactionSynchronizationManager

Spring binds all transaction context to the current thread using ThreadLocal variables inside TransactionSynchronizationManager:

public abstract class TransactionSynchronizationManager {
    // Map of active resources: DataSource -> ConnectionHolder (or SessionFactory -> SessionHolder)
    private static final ThreadLocal<Map<Object, Object>> resources =
            new NamedThreadLocal<>("Transactional resources");

    // Registered synchronization callbacks
    private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
            new NamedThreadLocal<>("Transaction synchronizations");

    private static final ThreadLocal<String> currentTransactionName =
            new NamedThreadLocal<>("Current transaction name");

    private static final ThreadLocal<Boolean> currentTransactionReadOnly =
            new NamedThreadLocal<>("Current transaction read-only status");

    private static final ThreadLocal<Integer> currentTransactionIsolationLevel =
            new NamedThreadLocal<>("Current transaction isolation level");

    private static final ThreadLocal<Boolean> actualTransactionActive =
            new NamedThreadLocal<>("Actual transaction active");
}

Suspend and Resume Micro-Operations:

When an inner method configured with REQUIRES_NEW executes, AbstractPlatformTransactionManager.suspend() performs the following sequence:

  1. Unbinds the current ConnectionHolder from ThreadLocal via unbindResource(dataSource).
  2. Suspends all registered synchronizations (TransactionSynchronization.suspend()), including the Hibernate L1 Persistence Context.
  3. Encapsulates this state into a SuspendedResourcesHolder instance.
  4. Checks out a new connection from HikariCP, binds it to ThreadLocal, sets autoCommit = false, and returns a new DefaultTransactionStatus.
  5. In a finally block after completion, resume() restores the SuspendedResourcesHolder back into ThreadLocal.

Highload Production Hazard: Connection Pool Deadlock with REQUIRES_NEW

In high-concurrency environments, nested REQUIRES_NEW transactions can cause complete connection pool starvation and JVM-wide deadlocks:

HikariCP Maximum Pool Size = 10 connections.
10 concurrent HTTP worker threads arrive simultaneously:

Thread 1: Starts outerMethod (REQUIRED)     -> Acquires Connection #1 from pool (held in suspend)
Thread 2: Starts outerMethod (REQUIRED)     -> Acquires Connection #2 from pool (held in suspend)
...
Thread 10: Starts outerMethod (REQUIRED)    -> Acquires Connection #10 from pool (held in suspend)

Available connections in HikariCP: 0!

Thread 1: Invokes innerMethod (REQUIRES_NEW)  -> Requests Connection #11 -> BLOCKS IN POOL...
Thread 2: Invokes innerMethod (REQUIRES_NEW)  -> Requests Connection #12 -> BLOCKS IN POOL...
...
Thread 10: Invokes innerMethod (REQUIRES_NEW) -> Requests Connection #20 -> BLOCKS IN POOL...

RESULT: Complete Deadlock! All 10 threads block waiting for each other to release connections!
After connectionTimeout (default 30s), all 10 requests crash with:
SQLTransientConnectionException: Connection is not available, request timed out after 30000ms.

Safe Sizing Formula for HikariCP with REQUIRES_NEW:

\(\text{PoolSize} \ge \text{MaxActiveThreads} \times (1 + \text{MaxNestedRequiresNew}) + \text{SafetyMargin}\)

The Self-Invocation Proxy Bypass Problem

Calling a @Transactional method from within the same class (this.anotherTransactionalMethod()) completely bypasses Spring AOP Proxy interception, causing the configured propagation settings to be silently ignored:

@Service
public class OrderService {

    @Transactional
    public void outer() {
        // HAZARD: propagation = REQUIRES_NEW IS IGNORED!
        // Direct call via 'this' pointer bypasses TransactionInterceptor!
        this.innerAudit();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void innerAudit() {
        // Runs within outer()'s transaction as if it were REQUIRED!
    }
}

Senior Engineering Solutions:

  1. Separate Bean Extraction (Best Practice): Move logic with independent transactional lifecycles to a dedicated service bean (AuditService).
  2. Self-Injection:
    @Service
    public class OrderService {
        @Autowired
        private OrderService self; // Injects the Spring AOP proxy
    
        @Transactional
        public void outer() {
            self.innerAudit(); // Routes through TransactionInterceptor!
        }
    }
    
  3. AopContext.currentProxy(): Configure @EnableAspectJAutoProxy(exposeProxy = true) and call ((OrderService) AopContext.currentProxy()).innerAudit().

4 Tricky Questions

1. What happens if an outer method with Propagation.REQUIRED catches an exception thrown by an inner method also declared with Propagation.REQUIRED?

Answer: Upon exiting the outer method, Spring attempts to commit and throws an UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only.

Internal Mechanics:

  1. Both methods share the exact same physical database connection and TransactionStatus.
  2. When the inner method throws a RuntimeException, the inner proxy’s TransactionInterceptor catches it before returning control to the caller’s catch block.
  3. Because the transaction is shared, the inner interceptor invokes transactionStatus.setRollbackOnly() to prevent dirty data from committing.
  4. The outer method catches the exception and attempts to commit normally.
  5. The outer TransactionInterceptor checks transactionStatus.isRollbackOnly(). Seeing true, it issues a rollback and throws UnexpectedRollbackException to notify the client that its intent to commit was safely aborted.

2. Does JpaTransactionManager (Hibernate) support Propagation.NESTED out of the box?

Answer: No. Invoking a method marked with Propagation.NESTED under JpaTransactionManager results in an immediate exception: org.springframework.transaction.NestedTransactionNotSupportedException: JpaTransactionManager does not support nested transactions by default.

Why it is unsupported: Hibernate maintains an in-memory First-Level Cache (PersistenceContext). While a JDBC Savepoint successfully rolls back table rows and lock states inside the database engine, Hibernate has no mechanism to rewind its internal entity snapshot cache to match the state at the Savepoint. Allowing NESTED would result in subsequent flush() operations pushing corrupted, desynchronized entity graphs back into the database. NESTED is supported only by DataSourceTransactionManager when using plain JDBC, Spring JdbcTemplate, or MyBatis.


3. How do transaction ThreadLocal variables behave when transitioning to an asynchronous thread (@Async or CompletableFuture)?

Answer: Transaction state stored in TransactionSynchronizationManager is NOT automatically propagated to child threads. Spring uses standard ThreadLocal, not InheritableThreadLocal. When an asynchronous boundary is crossed:

  1. The new worker thread starts with a completely empty TransactionSynchronizationManager.
  2. Any repository operations in the child thread execute in auto-commit mode or open an entirely separate physical transaction on a different connection.
  3. The child thread cannot see uncommitted mutations made by the parent thread (under Read Committed and above).
  4. Committing or rolling back the parent transaction has zero impact on the child thread’s writes, breaking atomicity across the operation.

4. What is the fundamental difference between Propagation.NOT_SUPPORTED and a method that lacks the @Transactional annotation entirely?

Answer:

  • Unannotated Method: If an unannotated method is invoked via a Spring bean proxy from within an active transaction, it simply executes inside that existing transaction, inheriting its connection and locks.
  • Propagation.NOT_SUPPORTED: Spring’s AOP interceptor explicitly suspends the caller’s active transaction (suspend()), detaches the database connection from ThreadLocal, and executes the method body non-transactionally. Once the method completes, the suspended transaction and its connection are safely restored (resume()).

🎯 Interview Cheat Sheet

  • Core Definition: Propagation defines how transaction boundaries behave when calling between @Transactional methods.
  • Default: Propagation.REQUIRED (participates in existing transaction, or starts a new one if none exists).
  • The 7 Propagation Modes: REQUIRED, REQUIRES_NEW, NESTED, MANDATORY, SUPPORTS, NOT_SUPPORTED, NEVER.
  • The Rollback-Only Trap: Catching an exception from an inner REQUIRED method still causes UnexpectedRollbackException on commit.
  • REQUIRES_NEW Cost: Suspends the outer transaction and checks out an additional physical connection from the pool.
  • Connection Pool Deadlock: In concurrent services, nested REQUIRES_NEW calls can exhaust the connection pool and deadlock the JVM.
  • Self-Invocation Issue: Calling an @Transactional method within the same class (this.method()) bypasses the Spring AOP proxy, ignoring propagation.
  • JPA & NESTED: Hibernate’s JpaTransactionManager does not support NESTED out of the box due to L1 cache rollback limitations.