What is Propagation in Spring
Spring implements propagation through AOP Proxy and TransactionInterceptor.
🟢 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:
auditService.logPayment()used REQUIRED — part of the overall transaction- On
gateway.charge()exception → entire transaction rolled back, including audit - 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]]