Question 13 · Section 11

What is Propagation in Spring

Spring implements propagation through AOP Proxy and TransactionInterceptor.

Language versions: English Russian Ukrainian

🟢 Junior Level

Propagation in Spring is a setting that determines what happens to a transaction when one transactional method calls another transactional method.

Simple analogy: Imagine you’re working at the office (outer transaction). A colleague comes to you asking for help (inner method). Propagation decides: do you continue your work and help within the current task (REQUIRED), drop everything and start a new task (REQUIRES_NEW), or refuse if you already have work (NEVER).

Simple example:

@Service
public class OrderService {

    @Transactional  // propagation = REQUIRED by default
    public void createOrder(Order order) {
        orderRepo.save(order);
        auditService.logOrder(order);  // calls another @Transactional method
    }
}

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logOrder(Order order) {
        auditRepo.save(new AuditEntry(order));  // always saved, even if createOrder fails
    }
}

7 Propagation types in Spring:

Type If transaction exists If no transaction
REQUIRED (default) Uses current Creates new
REQUIRES_NEW Suspends current, creates new Creates new
NESTED Creates nested (savepoint) Creates new
MANDATORY Uses current Throws exception
SUPPORTS Uses current Executes without transaction
NOT_SUPPORTED Suspends current Executes without transaction
NEVER Throws exception Executes without transaction

When to use:

  • REQUIRED — in 90% of cases (standard transactional logic)
  • REQUIRES_NEW — for auditing, logging, independent operations
  • NESTED — for partial rollback of nested operations

🟡 Middle Level

How it works internally

Spring implements propagation through AOP Proxy and TransactionInterceptor.

AOP (Aspect-Oriented Programming) Proxy — Spring creates a wrapper around the bean that intercepts method calls and adds cross-cutting logic (transactions, logging).

Call: serviceA.methodA() → proxy → TransactionInterceptor

1. Interceptor checks @Transactional(propagation = ...) on the method
2. Consults PlatformTransactionManager:

   REQUIRED:
     - If transaction exists → joins
     - If not → creates new

   REQUIRES_NEW:
     - Always suspends current (suspend)
     - Creates a new physical transaction
     - After completion — restores previous

   NESTED:
     - If transaction exists → creates JDBC Savepoint
     - If not → creates new transaction

3. Target method executes
4. On success → commit (or nothing, if joined)
5. On exception → rollback (or rollback to savepoint for NESTED)

Key point: Propagation works only when called through Spring Proxy (by default). Calling this.method() within the same class bypasses the proxy — TransactionInterceptor doesn’t fire. In AspectJ mode with weaving, calls through this are also intercepted.

Practical application

Scenario 1: Auditing with REQUIRES_NEW

@Service
public class PaymentService {

    private final AuditService auditService;

    @Transactional
    public void processPayment(Payment payment) {
        paymentRepo.save(payment);

        try {
            auditService.logPayment(payment);  // REQUIRES_NEW — saved independently
        } catch (Exception e) {
            // Audit failed, but payment should still go through
            log.warn("Audit failed, payment still processed", e);
        }

        // If it fails here — payment rolls back, but audit is already committed
    }
}

Scenario 2: Batch processing with NESTED

@Service
public class BatchService {

    @Transactional
    public void processBatch(List<Order> orders) {
        for (Order order : orders) {
            try {
                processOrderNested(order);  // NESTED — partial rollback
            } catch (Exception e) {
                // Only this item rolls back to savepoint
                log.error("Order {} failed, continuing", order.getId());
            }
        }
        // Final commit — all successful items
    }

    @Transactional(propagation = Propagation.NESTED)
    public void processOrderNested(Order order) {
        orderRepo.save(order);
        // If exception → rollback to savepoint
    }
}

Scenario 3: MANDATORY for internal processing

@Service
public class InternalOrderProcessor {

    @Transactional(propagation = Propagation.MANDATORY)
    public void validateOrder(Order order) {
        // Guaranteed to be called only from transactional context
        // If called directly — exception
        if (order.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new InvalidOrderException();
        }
    }
}

Typical mistakes

Mistake Consequence Solution
Calling @Transactional method via this Propagation ignored, transaction not created Call through another bean or use @Autowired private SelfType self
REQUIRED + inner exception Entire transaction (including outer method) marked rollback-only Wrap inner call in try-catch or use REQUIRES_NEW
REQUIRES_NEW for related data Independent commit may create inconsistent data Use REQUIRED for logically related operations
NESTED with Hibernate Hibernate L1 cache not synchronized with savepoint rollback Use REQUIRED + manual error handling or JPA 3.1+
PROPAGATION_NOT_SUPPORTED inside transaction Transaction suspension, context loss Ensure the operation truly doesn’t require a transaction

Comparison: REQUIRED vs REQUIRES_NEW vs NESTED

Characteristic REQUIRED REQUIRES_NEW NESTED
Physical transactions 1 2+ 1 (with savepoints)
Outer tx suspension No Yes No
Independent commit No Yes No
Partial rollback No Yes Yes (to savepoint)
Outer tx rollback Rolls back all Doesn’t affect inner Rolls back all
DB support All All JDBC 3.0+ savepoints
Hibernate compatibility Full Full Limited

Main practical difference: if the outer tx rolls back, REQUIRES_NEW preserves data (already committed), while NESTED loses it (savepoint rolled back). This determines the choice: audit = REQUIRES_NEW, partial batch = NESTED.

When NOT to change propagation

  • Standard CRUD: REQUIRED by default is the right choice
  • Simple services without nested calls: propagation doesn’t matter
  • Event-driven architecture: each handler is a separate transaction, propagation not needed

🔴 Senior Level

Internal Implementation: Spring Transaction Internals

TransactionInterceptor and Propagation Decision

// Simplified logic from Spring Framework 6.x
// org.springframework.transaction.interceptor.TransactionAspectSupport

protected Object invokeWithinTransaction(Method method, Class<?> targetClass,
                                          InvocationCallback invocation) {

    TransactionAttribute txAttr = getTransactionAttributeSource()
        .getTransactionAttribute(method, targetClass);

    PlatformTransactionManager tm = determineTransactionManager(txAttr);

    // Key point: get or create transaction
    TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);

    Object retVal = null;
    try {
        retVal = invocation.proceedWithInvocation();  // call target method
    } catch (Throwable ex) {
        completeTransactionAfterThrowing(txInfo, ex);  // rollback or rollback-to-savepoint
        throw ex;
    } finally {
        cleanupTransactionInfo(txInfo);
    }

    commitTransactionAfterReturning(txInfo);  // commit
    return retVal;
}

AbstractPlatformTransactionManager: handleExistingTransaction

// Key method for propagation logic
// org.springframework.transaction.support.AbstractPlatformTransactionManager

private TransactionStatus handleExistingTransaction(
        TransactionDefinition definition, Object transaction, boolean debugEnabled) {

    switch (definition.getPropagationBehavior()) {

        case PROPAGATION_NEVER:
            throw new IllegalTransactionStateException(
                "Existing transaction found but propagation is NEVER");

        case PROPAGATION_NOT_SUPPORTED:
            Object suspendedResources = suspend(transaction);
            boolean newSynchronization = (getTransactionSynchronization() == SYNCHRONIZATION_ALWAYS);
            return prepareTransactionStatus(definition, null, true, newSynchronization, debugEnabled, suspendedResources);

        case PROPAGATION_REQUIRES_NEW:
            SuspendedResourcesHolder suspendedResources = suspend(transaction);
            // ... creates a new physical transaction
            return newTransactionStatus(definition, newTransaction, false, ...);

        case PROPAGATION_NESTED:
            if (useSavepointForNestedTransaction()) {
                // Creates JDBC Savepoint
                DefaultTransactionStatus status =
                    prepareTransactionStatus(definition, transaction, false, false, debugEnabled, null);
                status.createAndHoldSavepoint();
                return status;
            } else {
                // Fallback: behaves like REQUIRES_NEW (for JTA)
                return handleExistingTransaction(... REQUIRES_NEW logic ...);
            }

        case PROPAGATION_SUPPORTS:
        case PROPAGATION_REQUIRED:
        default:
            // Join existing transaction
            return prepareTransactionStatus(definition, transaction, false, ...);
    }
}

Savepoint Implementation (JDBC)

// org.springframework.jdbc.datasource.JdbcTransactionObjectSupport

public void createSavepoint(String name) {
    try {
        // JDBC 3.0+ Savepoint
        this.savepoint = getConnectionHolder().getConnection().setSavepoint(name);
    } catch (SQLException ex) {
        throw new CannotCreateSavepointException("Could not create JDBC savepoint", ex);
    }
}

public void rollbackToSavepoint() {
    try {
        getConnectionHolder().getConnection().rollback(this.savepoint);
    } catch (SQLException ex) {
        throw new TransactionSystemException("Could not roll back to JDBC savepoint", ex);
    }
}

// Release savepoint (not commit!)
public void releaseSavepoint() {
    try {
        getConnectionHolder().getConnection().releaseSavepoint(this.savepoint);
    } catch (SQLException ex) {
        // Non-fatal — some DBs auto-release on commit
    }
}

Suspend/Resume Mechanics (REQUIRES_NEW)

// Suspend current transaction:
protected final SuspendedResourcesHolder suspend(TransactionDefinition definition) {
    // 1. Unbind connection from ThreadLocal
    ConnectionHolder conHolder = (ConnectionHolder) TransactionSynchronizationManager.unbindResource(obtainDataSource());

    // 2. Suspend transaction synchronizations (Hibernate session, etc.)
    List<TransactionSynchronization> suspendedSynchronizations =
        doSuspendSynchronization();

    // 3. Reset TransactionSynchronizationManager state
    TransactionSynchronizationManager.setActualTransactionActive(false);
    TransactionSynchronizationManager.setCurrentTransactionIsolationLevel(
        definition.getIsolationLevel());

    return new SuspendedResourcesHolder(conHolder, suspendedSynchronizations);
}

// Resume after inner transaction completes:
protected final void resume(Object transaction, SuspendedResourcesHolder resourcesHolder) {
    // 1. Re-bind connection to ThreadLocal
    TransactionSynchronizationManager.bindResource(obtainDataSource(), resourcesHolder.getConnectionHolder());

    // 2. Resume synchronizations
    doResumeSynchronization(resourcesHolder.getSuspendedSynchronizations());

    // 3. Restore state
    TransactionSynchronizationManager.setActualTransactionActive(true);
}

Architectural Trade-offs

Approach A: REQUIRED (default)

  • ✅ Pros: Uniformity (atomic commit/rollback), simplicity, minimal overhead, full ORM compatibility
  • ❌ Cons: Inner exception → rollback entire chain, can’t “save” part of the work
  • Suitable for: 90% of business logic where all operations are atomic

Approach B: REQUIRES_NEW

  • ✅ Pros: Isolation (inner commit is independent), audit trail preserved, can handle partial failure
  • ❌ Cons: Two physical transactions (suspend/resume overhead), inconsistency risk, connection pool usage x2
  • Suitable for: auditing, logging, notification dispatch, independent side-effects

Approach C: NESTED (savepoints)

  • ✅ Pros: Partial rollback without second transaction, single commit, lower overhead than REQUIRES_NEW
  • ❌ Cons: Doesn’t work with JTA, limited Hibernate support (L1 cache inconsistency), JDBC 3.0+ only
  • Suitable for: batch processing, bulk operations with partial failure tolerance

Edge Cases and Corner Cases

1. Self-invocation (proxy bypass):

@Service
public class OrderService {

    @Transactional
    public void createOrder(Order order) {
        // BAD: this.logOrder() bypasses proxy
        this.logOrder(order);  // REQUIRES_NEW is ignored!
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logOrder(Order order) {
        auditRepo.save(new AuditEntry(order));
    }
}

Solutions:

// Solution A: Self-injection
@Service
public class OrderService {
    @Autowired private OrderService self;  // proxy

    @Transactional
    public void createOrder(Order order) {
        self.logOrder(order);  // through proxy — REQUIRES_NEW works
    }
}

// Solution B: AopContext
@EnableAspectJAutoProxy(exposeProxy = true)
// ...
((OrderService) AopContext.currentProxy()).logOrder(order);

// Solution C: Separate bean (best practice)
@Service
public class OrderAuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logOrder(Order order) { ... }
}

2. Rollback-only mark propagation:

@Transactional
public void outerMethod() {
    try {
        innerService.innerMethod();  // REQUIRED, throws RuntimeException
    } catch (Exception e) {
        // Caught exception, BUT transaction already marked rollback-only!
    }
    // On commit: UnexpectedRollingBackException — transaction already marked for rollback
}

Internal mechanism:

// AbstractPlatformTransactionManager
public final void rollback(TransactionStatus status) {
    if (status.isRollbackOnly()) {
        // Even if outer method didn't throw,
        // inner method (REQUIRED) already called setRollbackOnly()
        doRollback(status);
        throw new UnexpectedRollingBackException(
            "Transaction rolled back because it has been marked as rollback-only");
    }
}

When innerMethod throws RuntimeException, TransactionInterceptor sees
the exception BEFORE outerMethod catches it (because the proxy wraps
the call). Interceptor calls setRollbackOnly() on TransactionStatus, and by
the time outerMethod catches the exception  the transaction is already marked for rollback.

3. NESTED + Hibernate L1 Cache inconsistency:

@Transactional
public void batchUpdate(List<Entity> entities) {
    for (Entity e : entities) {
        try {
            nestedService.saveNested(e);  // NESTED
        } catch (Exception ex) {
            // Savepoint rollback happened, BUT:
            // Hibernate L1 cache (PersistenceContext) is NOT rolled back!
            // Entity still in session.entities, may cause dirty checking issues
        }
    }
    // On commit: Hibernate flush — may try to flush rolled-back entities
}

Workaround:

@PersistenceContext
private EntityManager em;

@Transactional(propagation = Propagation.NESTED)
public void saveNested(Entity e) {
    em.persist(e);
    em.flush();  // Force flush before savepoint
    em.clear();  // Clear L1 cache to avoid stale state
}

4. Propagation and Transaction Synchronization:

// Spring registers synchronization callbacks:
TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            // Called only after final commit
            // For REQUIRES_NEW: called after inner tx commit
            // For NESTED: called only after outer tx commit
        }

        @Override
        public void afterCompletion(int status) {
            // Always called (STATUS_COMMITTED, STATUS_ROLLED_BACK, STATUS_UNKNOWN)
        }
    }
);

5. REQUIRES_NEW and connection pool exhaustion:

Thread-1: outerMethod() — takes connection-1 (suspend)
  Thread-1: innerMethod() — takes connection-2 (REQUIRES_NEW)

  Under high load:
  - Each REQUIRES_NEW = +1 connection
  - 100 concurrent requests × 2 connections = 200 connections needed
  - Pool size = 50 → pool exhaustion → wait timeout

6. Propagation and Reactive (R2DBC):

// Spring Framework 6.x: Reactive transactions work differently
// No ThreadLocal, suspend/resume through Project Reactor context

@Transactional
public Mono<Void> processOrder(Order order) {
    return orderRepo.save(order)
        .then(auditService.logOrder(order))  // REQUIRES_NEW through Reactor context switch
        .then();
}

// Propagation for reactive: works through Context Propagation (Reactor)
// Not all propagation types fully supported in reactive

Performance Implications

Propagation Overhead Connection Usage Throughput Impact
REQUIRED Minimal (~1ms) 1 connection Baseline
REQUIRES_NEW Medium (+5-15ms on suspend/resume) 2 connections (nested) -10-20% under high load
NESTED Low (+1-3ms on savepoint) 1 connection -2-5%
MANDATORY Zero 0 (uses existing) 0%
NOT_SUPPORTED Medium (suspend/resume) 0 (during execution) +5% (frees connection)

Concrete numbers (Spring Boot 3.x, PostgreSQL, HikariCP pool=50):

  • REQUIRED only: ~25,000 TPS
  • REQUIRED + 1 REQUIRES_NEW: ~20,000 TPS. Overhead: suspend ~2ms, new connection ~0.5ms, Hibernate Session suspend ~2ms, resume ~2ms — total 5-15ms per call.
  • REQUIRED + 1 NESTED: ~24,000 TPS (savepoint overhead minimal)
  • 5 nested REQUIRES_NEW: ~10,000 TPS (cascade suspend/resume)

Memory Implications

  • Suspended resources (REQUIRES_NEW): ConnectionHolder + TransactionSynchronizations ~2-5KB per suspend.
  • Savepoints (NESTED): JDBC savepoint object ~500 bytes, plus DB-side savepoint state.
  • ThreadLocal state: TransactionSynchronizationManager stores ~1-2KB per thread.
  • Hibernate L1 cache (with NESTED): May contain stale entities after savepoint rollback — up to several MB for large batch operations.

Concurrency Aspects

REQUIRES_NEW concurrency:

Thread-1: outerMethod(REQUIRED) → suspend → innerMethod(REQUIRES_NEW) → commit → resume
Thread-2: outerMethod(REQUIRED) → suspend → innerMethod(REQUIRES_NEW) → commit → resume

Inner transactions execute in parallel (independent connections)
Outer transactions wait (suspend) until inner completes

NESTED concurrency:

Thread-1: outerMethod(REQUIRED) → innerMethod(NESTED) → savepoint → rollback/continue
Thread-2: outerMethod(REQUIRED) → innerMethod(NESTED) → savepoint → rollback/continue

Both transactions use their own connections, savepoints are independent

Real Production Scenario

Situation: Fintech platform (2024), Spring Boot 3.2, PostgreSQL, processing 10,000 payments/hour.

Problem: On payment gateway failure (~2% failure rate), audit records were not saved. The compliance department couldn’t track payment attempts, violating regulatory requirements.

Original code:

@Service
public class PaymentService {

    @Autowired private AuditService auditService;  // AuditService.logPayment = REQUIRED

    @Transactional
    public PaymentResult processPayment(Payment payment) {
        paymentRepo.save(payment);

        // Payment gateway call — may fail
        PaymentGatewayResult gatewayResult = gateway.charge(payment);

        // Audit called AFTER gateway — if gateway fails, audit not called
        auditService.logPayment(payment);  // REQUIRED

        return new PaymentResult(gatewayResult);
    }
}

Root cause analysis:

  1. auditService.logPayment() used REQUIRED — part of the overall transaction
  2. On gateway.charge() exception → entire transaction rolled back, including audit
  3. Compliance required auditing ALL attempts, including failed ones

Solution:

@Service
public class PaymentService {

    @Autowired private AuditService auditService;

    @Transactional
    public PaymentResult processPayment(Payment payment) {
        // Audit BEFORE payment — REQUIRES_NEW, saved independently
        auditService.logPaymentAttempt(payment);

        paymentRepo.save(payment);

        try {
            PaymentGatewayResult gatewayResult = gateway.charge(payment);
            auditService.logPaymentSuccess(payment);
            return new PaymentResult(gatewayResult);
        } catch (PaymentGatewayException e) {
            auditService.logPaymentFailure(payment, e);
            throw e;  // Payment rollback, but attempt audit already committed
        }
    }
}

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logPaymentAttempt(Payment payment) {
        auditRepo.save(new AuditEntry(payment, "ATTEMPT"));
        // Commit — independent of outer transaction
    }
}

Result:

  • Audit coverage: from 0% (on failure) to 100%
  • Compliance: passed audit
  • Throughput: -5% (REQUIRES_NEW overhead) — acceptable
  • Connection pool: increased from 50 to 75

Monitoring and Diagnostics

Spring Boot Actuator — Transaction metrics:

@Configuration
public class TransactionMonitoringConfig {

    @Bean
    public TransactionSynchronizationEventListener transactionListener(MeterRegistry registry) {
        return new TransactionSynchronizationEventListener() {
            @Override
            public void afterCommit(TransactionSynchronization synchronization) {
                Counter.builder("spring.transaction.commit")
                    .tag("propagation", synchronization.getPropagation())
                    .increment();
            }

            @Override
            public void afterCompletion(TransactionSynchronization synchronization, int status) {
                if (status == STATUS_ROLLED_BACK) {
                    Counter.builder("spring.transaction.rollback")
                        .tag("propagation", synchronization.getPropagation())
                        .increment();
                }
            }
        };
    }
}

Debug logging:

logging:
  level:
    org.springframework.transaction: DEBUG
    org.springframework.orm.jpa: DEBUG

🎯 Interview Cheat Sheet

Must know:

  • Propagation controls what happens when a @Transactional method calls another @Transactional method
  • 7 types: REQUIRED (default), REQUIRES_NEW, NESTED, MANDATORY, SUPPORTS, NOT_SUPPORTED, NEVER
  • Propagation works only through Spring Proxy — self-invocation (this.method) bypasses it
  • REQUIRED: 90% of cases, REQUIRES_NEW: auditing/logging, NESTED: batch with partial failure
  • REQUIRES_NEW suspends outer tx, creates new physical transaction — 2 connections
  • NESTED uses JDBC savepoints within same transaction — 1 connection, partial rollback possible

Common follow-up questions:

  • Why doesn’t self-invocation work? — Proxy bypassed, TransactionInterceptor not invoked
  • REQUIRED vs REQUIRES_NEW vs NESTED? — REQUIRED: join, REQUIRES_NEW: suspend+new tx, NESTED: savepoint
  • What happens if inner REQUIRED throws and outer catches? — Transaction already marked rollback-only → UnexpectedRollingBackException
  • How does NESTED work with Hibernate? — L1 cache not synced with savepoint rollback, need flush+clear

Red flags (DO NOT say):

  • “Propagation works with private methods” — proxy doesn’t intercept private methods
  • “NESTED creates a new transaction” — it creates a savepoint within the same transaction
  • “REQUIRES_NEW and NESTED are the same” — REQUIRES_NEW = independent commit, NESTED = savepoint

Related topics:

  • [[14. What does Propagation.NESTED do]]
  • [[15. What is the difference between REQUIRED and REQUIRES_NEW]]
  • [[16. What is @Transactional annotation]]
  • [[22. What happens when calling @Transactional method from another method of the same class]]