Definition
- 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:
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
}
}
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.
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);
}
}
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.
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
}
}
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));
}
}
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.
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);
}
}
}
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.
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
}
}
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.
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
}
}
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.
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));
}
}
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 viathis.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
JpaTransactionManagerdoes not support savepoints through the JPA API. NESTED only works with plainDataSourceTransactionManagerand 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.
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"));
}
}
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
┌─────────────────────────────────────────────────────────┐ │ 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.
---