Back to Transaction Propagation
Q1Question 1 of 4

What are transaction propagation levels?

Key Concepts
  • REQUIRED (default): joins the existing transaction or creates a new one if none exists
  • REQUIRES_NEW: always starts a new independent transaction by suspending the current one
  • MANDATORY: must run within an existing transaction or throws IllegalTransactionStateException
  • SUPPORTS: runs in a transaction if one exists, runs non-transactionally otherwise
  • NOT_SUPPORTED: suspends any existing transaction and runs non-transactionally
  • NEVER: throws an exception if a transaction already exists
  • NESTED: creates a savepoint within the existing transaction for partial rollback (JDBC only, not JPA)

Definition

Propagation controls what happens when one @Transactional method calls another -- REQUIRED joins or creates, REQUIRES_NEW always starts fresh, MANDATORY insists on an existing transaction, and NESTED creates a savepoint.
  • REQUIRED (default) -- joins the caller's transaction if one exists, or creates a new one if none exists. All methods in the call chain share one physical transaction and commit or roll back together.
  • REQUIRES_NEW -- always creates a new, independent transaction by suspending the caller's transaction. The inner transaction commits or rolls back independently of the outer one.
  • MANDATORY -- requires an existing transaction. If called without an active transaction, Spring throws IllegalTransactionStateException.
  • SUPPORTS -- runs inside a transaction if one exists, or runs non-transactionally if none exists.
  • NOT_SUPPORTED -- suspends any existing transaction and runs the method without transactional behavior.
  • NEVER -- throws an exception if a transaction is currently active. Ensures the method never runs inside a transaction.

At the National Bank of India, REQUIRED is like a clerk who joins the existing team if one is working on a task, or starts a new team if nobody is working. REQUIRES_NEW is a compliance auditor who always works independently, because the audit record must survive even if the main team's work is undone. MANDATORY is a junior clerk who refuses to work alone and insists on being part of an existing team. NESTED is a clerk who creates a checkpoint within the team's work -- the checkpoint can be undone without undoing everything the team has done so far.

Understanding the Concepts

What is transaction propagation?

Transaction propagation is a Spring concept that defines how a @Transactional method behaves when it is called from inside another transactional method. It answers the question: should the inner method join the caller's transaction, start its own, or refuse to run?

Spring provides seven propagation levels through the Propagation enum. Each level is set on the @Transactional annotation:

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class LoanDisbursementService {

    @Transactional(propagation = Propagation.REQUIRED) // default
    public void disburse(String loanId) {
        // Joins existing TX or creates new one
    }
}
TEXT
If disburse() is called without an active TX:
  -> Spring creates a new transaction
If disburse() is called within an active TX:
  -> Spring joins that existing transaction

How does REQUIRED propagation work?

REQUIRED is the default propagation. When a REQUIRED method is called, Spring checks whether a transaction already exists. If one does, the method joins it. If none exists, Spring creates a new one. All REQUIRED methods in the call chain share one physical transaction. They commit together or roll back together.

This is the most common propagation level and is used for the vast majority of business operations.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class TransferService {

    private final AccountRepository accountRepository;
    private final LedgerService ledgerService;

    public TransferService(AccountRepository accountRepository,
                           LedgerService ledgerService) {
        this.accountRepository = accountRepository;
        this.ledgerService = ledgerService;
    }

    @Transactional // REQUIRED by default
    public void transfer(String fromId, String toId, BigDecimal amount) {
        var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        var to = accountRepository.findByIdForUpdate(toId).orElseThrow();

        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));

        // ledgerService.record() also has @Transactional(REQUIRED)
        // It joins THIS transaction -- all commit or rollback together
        ledgerService.record(fromId, toId, amount);
    }
}
TEXT
transfer() creates TX-1
  debit SAV2001  (in TX-1)
  credit CUR3001 (in TX-1)
  ledgerService.record() joins TX-1
    insert ledger entry (in TX-1)
  COMMIT TX-1 -> all three operations commit atomically

How does REQUIRES_NEW propagation work?

REQUIRES_NEW always creates a new, independent transaction. If a transaction already exists, Spring suspends it, starts a fresh transaction for the inner method, and resumes the outer transaction after the inner one finishes. The inner and outer transactions commit or roll back independently.

The classic use case is audit logging: the audit record must persist even if the main business transaction rolls back.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Service
public class AuditService {

    private final AuditRepository auditRepository;

    public AuditService(AuditRepository auditRepository) {
        this.auditRepository = auditRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAttempt(String fromId, String toId,
                           BigDecimal amount, String status) {
        AuditEntry entry = new AuditEntry();
        entry.setFromAccount(fromId);
        entry.setToAccount(toId);
        entry.setAmount(amount);
        entry.setStatus(status);  // "ATTEMPTED" or "FAILED"
        entry.setTimestamp(LocalDateTime.now());
        auditRepository.save(entry);
        // This commits in its OWN transaction
    }
}
JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class AuditedTransferService {

    private final AccountRepository accountRepository;
    private final AuditService auditService;

    public AuditedTransferService(AccountRepository accountRepository,
                                   AuditService auditService) {
        this.accountRepository = accountRepository;
        this.auditService = auditService;
    }

    @Transactional
    public void transfer(String fromId, String toId, BigDecimal amount) {
        auditService.logAttempt(fromId, toId, amount, "ATTEMPTED");

        var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        if (from.getBalance().compareTo(amount) < 0) {
            throw new InsufficientFundsException(fromId, amount);
        }

        var to = accountRepository.findByIdForUpdate(toId).orElseThrow();
        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
    }
}
TEXT
transfer() starts TX-1
  auditService.logAttempt() -> TX-1 SUSPENDED
    TX-2 created (REQUIRES_NEW)
    INSERT audit entry
    COMMIT TX-2 -> audit record is permanent
  TX-1 RESUMED
  debit SAV2001
  InsufficientFundsException thrown!
  ROLLBACK TX-1 -> debit is undone
  But audit record from TX-2 survives -- regulatory compliance met

How does MANDATORY propagation work?

MANDATORY requires that a transaction already exists when the method is called. If no transaction is active, Spring throws IllegalTransactionStateException. This is used for helper methods that must never run standalone because their work only makes sense as part of a larger operation.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class ComplianceService {

    private final ComplianceRepository complianceRepository;

    public ComplianceService(ComplianceRepository complianceRepository) {
        this.complianceRepository = complianceRepository;
    }

    @Transactional(propagation = Propagation.MANDATORY)
    public void checkTransferCompliance(String fromId, String toId,
                                         BigDecimal amount) {
        // Must run within the caller's transaction
        // If the caller rolls back, this check is also rolled back
        if (amount.compareTo(new BigDecimal("1000000")) > 0) {
            complianceRepository.flagForReview(fromId, toId, amount);
        }
    }
}
TEXT
Called within a transaction:
  transfer() [TX-1] -> complianceService.checkTransferCompliance() [joins TX-1]
  Works correctly.

Called without a transaction:
  complianceService.checkTransferCompliance()  // no active TX
  -> throws IllegalTransactionStateException:
     "No existing transaction found for transaction marked with propagation 'mandatory'"

What are the remaining propagation levels?

The remaining three levels cover edge cases:

  • SUPPORTS -- runs inside a transaction if one exists, but runs non-transactionally if none exists. Useful for methods that can work both ways, such as a read query that benefits from a transaction's snapshot but does not require one.
  • NOT_SUPPORTED -- suspends any existing transaction and runs the method without transactional behavior. Useful for long-running operations (like report generation) that should not hold a transaction open.
  • NEVER -- throws an exception if a transaction is currently active. Used as a safety guard on methods that must never participate in a transaction.
JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Service
public class ReportService {

    private final AccountRepository accountRepository;

    public ReportService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional(propagation = Propagation.SUPPORTS)
    public List<Account> getAccounts() {
        // With TX: uses the transaction's snapshot
        // Without TX: runs a plain query
        return accountRepository.findAll();
    }

    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public byte[] generateLargeReport() {
        // Suspends any existing TX to avoid holding it during slow I/O
        return buildReport();
    }

    @Transactional(propagation = Propagation.NEVER)
    public void sendNotification(String message) {
        // Must never run inside a TX -- throws if one exists
        // Forces callers to move this outside the transaction boundary
    }

    private byte[] buildReport() {
        return new byte[0]; // placeholder
    }
}
TEXT
SUPPORTS:
  Called within TX -> joins TX, uses snapshot
  Called without TX -> runs without TX, plain query

NOT_SUPPORTED:
  Called within TX -> TX suspended, runs without TX
  Called without TX -> runs without TX

NEVER:
  Called within TX -> throws IllegalTransactionStateException
  Called without TX -> runs without TX

How does NESTED propagation work?

NESTED creates a savepoint within the existing transaction. If the nested method fails, the transaction rolls back to the savepoint, not to the beginning. The outer transaction can catch the exception and continue with the remaining work.

NESTED only works with JDBC DataSourceTransactionManager. It does not work with JPA/Hibernate JpaTransactionManager because Hibernate does not support savepoints through the standard JPA API.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class BonusService {

    private final AccountRepository accountRepository;

    public BonusService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional(propagation = Propagation.NESTED)
    public void applyBonus(String accountId, BigDecimal bonus) {
        // Creates a SAVEPOINT within the outer TX
        var account = accountRepository.findById(accountId).orElseThrow();
        account.setBalance(account.getBalance().add(bonus));
        // If this fails, only the savepoint rolls back
        // The outer TX can catch the exception and continue
    }
}
TEXT
transfer() starts TX-1
  debit SAV2001 (in TX-1)
  credit CUR3001 (in TX-1)
  SAVEPOINT created
  bonusService.applyBonus() -> fails!
  ROLLBACK TO SAVEPOINT -> bonus undone, debit+credit preserved
  Outer TX catches exception and commits
  Result: transfer committed, bonus skipped

What are the comparison differences between all propagation levels?

| Propagation | Existing TX present | No existing TX | Use case |
|---|---|---|---|
| REQUIRED | Joins it | Creates new | Default for all business methods |
| REQUIRES_NEW | Suspends it, creates new | Creates new | Audit logs, independent commits |
| MANDATORY | Joins it | Throws exception | Helper methods that must not run alone |
| SUPPORTS | Joins it | Runs without TX | Read queries that optionally use TX |
| NOT_SUPPORTED | Suspends it | Runs without TX | Long-running reports, non-TX work |
| NEVER | Throws exception | Runs without TX | Safety guard against accidental TX |
| NESTED | Creates savepoint | Creates new | Partial rollback within a TX (JDBC only) |

What are common mistakes with propagation?

The most common mistake is expecting REQUIRES_NEW to be "safer" than REQUIRED and using it everywhere. REQUIRES_NEW holds two database connections simultaneously (the suspended outer and the new inner), which can exhaust the connection pool under load.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class MistakeLedgerService {

    private final LedgerRepository ledgerRepository;

    public MistakeLedgerService(LedgerRepository ledgerRepository) {
        this.ledgerRepository = ledgerRepository;
    }

    // WRONG: REQUIRES_NEW on a regular ledger entry
    // This holds TWO connections and gains nothing
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordWrong(String fromId, String toId, BigDecimal amount) {
        ledgerRepository.save(new LedgerEntry(fromId, toId, amount));
    }

    // CORRECT: REQUIRED -- shares the caller's TX
    // Ledger entry commits or rolls back WITH the transfer
    @Transactional(propagation = Propagation.REQUIRED)
    public void recordCorrect(String fromId, String toId, BigDecimal amount) {
        ledgerRepository.save(new LedgerEntry(fromId, toId, amount));
    }
}
TEXT
WRONG (REQUIRES_NEW on ledger):
  Thread holds connection-1 (outer TX, suspended)
  Thread holds connection-2 (inner TX, REQUIRES_NEW)
  2 connections per thread, pool exhaustion under load
  Ledger can commit while transfer rolls back -- data inconsistency!

CORRECT (REQUIRED on ledger):
  Thread holds connection-1 (shared TX)
  1 connection per thread
  Ledger and transfer commit or rollback together -- consistent

What should a senior engineer know?

  • REQUIRES_NEW connection pool sizing -- each REQUIRES_NEW call holds two connections (outer suspended, inner active). HikariCP must be sized with the formula: maxPoolSize >= threads * (maxNestedDepth - 1) + 1.
  • Self-invocation bypasses propagation -- calling a @Transactional(REQUIRES_NEW) method from within the same class via this.method() bypasses the proxy. The method runs with whatever transaction the caller had, not a new one. Always call through an injected bean reference.
  • NESTED is JPA-incompatible -- Hibernate's JpaTransactionManager does not support savepoints through the JPA API. NESTED only works with plain DataSourceTransactionManager and JDBC.
  • Prefer AFTER_COMMIT events over REQUIRES_NEW for notifications -- instead of using REQUIRES_NEW to ensure an audit log commits independently, use @TransactionalEventListener(phase = AFTER_COMMIT) to fire after the main transaction succeeds. This avoids holding two connections.
JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;

@Service
public class AuditEventListener {

    private final AuditRepository auditRepository;

    public AuditEventListener(AuditRepository auditRepository) {
        this.auditRepository = auditRepository;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onTransferCommitted(TransferCompletedEvent event) {
        // Fires ONLY after the main TX commits
        // Runs in its own TX, uses only one connection
        auditRepository.save(new AuditEntry(
            event.fromId(), event.toId(), event.amount(), "COMMITTED"));
    }
}
TEXT
With AFTER_COMMIT listener:
  TX-1: transfer + publish event -> COMMIT
  After commit: listener fires in new TX-2
  Only one connection held at any time

What are common interview follow-up questions?

  • What is the default propagation? REQUIRED.
  • When would you use MANDATORY? For helper methods that must not run standalone, such as a compliance check that only makes sense within a transfer transaction.
  • Can REQUIRES_NEW roll back the outer transaction? No. They are independent. The outer transaction is suspended, not affected by the inner one's outcome.
  • What happens if NESTED fails and the outer catches the exception? Only the savepoint rolls back. The outer transaction continues with the work done before the savepoint.
  • Why is NESTED rarely used in Spring Boot? Because most Spring Boot applications use JPA with JpaTransactionManager, which does not support savepoints.

Visual Overview

DIAGRAM
┌─────────────────────────────────────────────────────────┐
│                   REQUIRED (default)                     │
│                                                          │
│  Caller has TX?                                          │
│    YES -> join existing TX (same physical TX)             │
│    NO  -> create new TX                                   │
│                                                          │
│  transfer() ──── TX-1 ────────────────────────────────── │
│    └─ ledger.record()  (joins TX-1)                      │
│    └─ All commit or rollback TOGETHER                    │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                   REQUIRES_NEW                            │
│                                                          │
│  Always suspends current TX, creates new TX               │
│                                                          │
│  transfer() ──── TX-1 ─── SUSPENDED ──── TX-1 RESUMED ── │
│    └─ audit.log()  ──── TX-2 ──── COMMIT                 │
│                                                          │
│  TX-2 commits independently of TX-1                      │
│  If TX-1 rolls back, TX-2 survives                       │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                    MANDATORY                              │
│                                                          │
│  Caller has TX?                                          │
│    YES -> join existing TX                                │
│    NO  -> throw IllegalTransactionStateException          │
│                                                          │
│  transfer() ──── TX-1 ────────────────────────────────── │
│    └─ compliance.check()  (joins TX-1 -- OK)             │
│                                                          │
│  compliance.check()  (no TX)                             │
│    └─ EXCEPTION! Must have active TX                     │
└─────────────────────────────────────────────────────────┘

Key Takeaway

Transaction propagation defines how a @Transactional method behaves when called within an existing transaction. REQUIRED, the default, joins the existing transaction or creates a new one. This is used for the vast majority of business operations because all methods in the call chain commit or roll back together. REQUIRES_NEW always creates a new independent transaction by suspending the outer one, which is essential for audit logs that must persist even when the main transaction rolls back. MANDATORY requires an existing transaction and throws if none exists, which is used for helper methods that must always participate in a caller's transaction.

In Rajesh's loan disbursement service at the National Bank of India, the main disbursement method uses REQUIRED. The compliance check uses MANDATORY to ensure it always runs within the disbursement's transaction. The audit service uses REQUIRES_NEW so that every attempt is recorded for regulatory compliance. When disbursement to a frozen account fails, the main transaction rolls back, but the REQUIRES_NEW audit record is already committed in its own independent transaction. Pankaj, the architect, warns that REQUIRES_NEW holds two database connections simultaneously because the outer transaction's connection is suspended while the inner transaction uses a new one.

The remaining propagation levels cover edge cases. SUPPORTS runs within a transaction if one exists but non-transactionally otherwise. NOT_SUPPORTED suspends any current transaction to avoid holding it during slow operations. NEVER throws if a transaction is present, acting as a safety guard. NESTED creates a savepoint within the existing transaction for partial rollback, though this only works with JDBC and not with JPA.

Remember one sentence: REQUIRED joins or creates (the default for everything), REQUIRES_NEW always creates its own independent transaction (for audit logs), and MANDATORY refuses to work without a caller's transaction.

---

Share
Use ← → arrow keys to navigate