Hook: Interviewers love this topic because it is where clean service code turns into real production bugs: missing audit rows, surprise rollbacks, and connection-pool timeouts.
Question: What is transactional propagation in Spring Boot?
Answer: Transactional propagation is the rule Spring uses to decide what to do when a transactional method is called while another transaction already exists. The most common mode is REQUIRED, which joins the current transaction or starts one if none exists. Other modes can suspend the current transaction, create a new one, or refuse to run unless a transaction is already present.
Interview-Ready Answer: In Spring Boot, transactional propagation tells Spring whether an inner method should join the current transaction, start a new one, or run without one. The default is REQUIRED, which is why most methods share the same unit of work. The important detail I remember is that REQUIRES_NEW suspends the outer transaction and uses a separate one, so an audit record can still commit even if the business transaction later rolls back.
Detailed Explanation: In Spring, propagation answers one simple question: if a transactional method is called while a transaction is already open, should Spring join it, start a new one, suspend it, or reject the call? A transaction is the atomic unit of work for the database: either all changes succeed together or all roll back together. Propagation is different from isolation; isolation is about visibility between transactions, while propagation is about how many transactions exist.
@Transactional is called through a Spring proxy. A proxy is a wrapper object that intercepts the call before your real method runs.TransactionInterceptor, which asks the current PlatformTransactionManager whether a transaction already exists on the current thread. A Thread-local store means each thread keeps its own data separately.REQUIRED, it joins the existing transaction or creates one if none exists. With REQUIRES_NEW, it suspends the current transaction and opens a fresh one.REQUIRED fails, the outer transaction is usually marked rollback-only. Even if you catch the exception, the final commit can still fail with UnexpectedRollbackException.REQUIRES_NEW fails, only that inner transaction rolls back; the outer transaction can still commit if it stays clean.NESTED is used, Spring creates a savepoint. A savepoint is a checkpoint inside one transaction that lets Spring roll back part of the work without rolling back the whole thing.| Mode | What happens | Typical use |
|---|---|---|
| REQUIRED | Join or create | Most service work |
| REQUIRES_NEW | Suspend old, start new | Audit, outbox |
| NESTED | Savepoint inside one tx | Partial rollback |
| SUPPORTS | Use if present | Read helpers |
| MANDATORY | Fail if none exists | Strict inner APIs |
| NEVER | Fail if tx exists | Must be non-tx |
| NOT_SUPPORTED | Suspend any tx | Slow non-tx work |
Use REQUIRED when the whole business action should succeed or fail together, such as creating an order and reserving inventory. Use REQUIRES_NEW for side effects that should survive the main rollback, such as audit logs, outbox messages, or security events. Use NESTED when you want a smaller rollback area inside a larger transaction, but only if your transaction manager and database support savepoints.
From a complexity point of view, deciding propagation is basically O(1); Spring checks the current transaction state and applies the rule. The real cost comes from what the rule forces Spring to do. REQUIRED is cheap because it usually reuses the existing context. REQUIRES_NEW costs more because Spring must suspend the old transaction, ask the pool for another connection, and later resume the original one.
That connection detail matters in production. HikariCP, the default pool in Spring Boot, commonly starts with a maximum pool size of 10 and a connection timeout of 30000 ms. If 10 request threads are already holding outer transactions and each one tries to open a REQUIRES_NEW inner transaction, the inner calls can block waiting for a free connection. Under load, that can look like random slowness or a timeout storm.
One more gotcha: propagation only works when Spring gets the call through its proxy. If one method in a class calls another @Transactional method in the same class, that self-invocation bypasses the proxy and the annotation is ignored. Also, NESTED is not universal; it depends on savepoint support, and JPA-based setups do not behave the same way as plain JDBC.
Real-World Example: In an e-commerce checkout service, the order row, inventory reservation, and payment state are usually wrapped in one REQUIRED transaction. The audit trail or outbox event is often written with REQUIRES_NEW so support and downstream systems still get a record even if payment fails later. That separation matters because business truth and observability do not always need the same rollback rules.
A real production bug looks like this: a team used REQUIRED for the audit insert. The payment gateway failed, the outer transaction rolled back, and the audit row vanished too. The symptoms were ugly: customers saw 500 errors, the database had no order, and support had no audit trail to explain what happened. The logs showed only the payment exception, so reconciliation became manual. In another batch job, using REQUIRES_NEW inside a tight loop caused connection starvation; Hikari started logging Connection is not available, request timed out after 30000ms, and throughput collapsed.
package com.example.txpropagation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.UnexpectedRollbackException;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@SpringBootApplication
@EnableTransactionManagement
public class TransactionalPropagationApplication {
public static void main(String[] args) {
SpringApplication.run(TransactionalPropagationApplication.class, args);
}
@Bean
CommandLineRunner demo(DemoScenarioRunner runner) {
return args -> runner.run();
}
}
@Component
class DemoScenarioRunner {
private final JdbcTemplate jdbcTemplate;
private final CheckoutService checkoutService;
@Autowired
DemoScenarioRunner(JdbcTemplate jdbcTemplate, CheckoutService checkoutService) {
this.jdbcTemplate = jdbcTemplate;
this.checkoutService = checkoutService;
}
public void run() {
initSchema();
// Always call transactional methods through another bean so Spring's proxy can intercept them.
System.out.println();
System.out.println("--- Scenario 1: REQUIRED inner failure marks the whole transaction rollback-only ---");
try {
checkoutService.placeOrderSwallowingRequiredFailure("ORD-1001");
} catch (UnexpectedRollbackException ex) {
System.out.println("Outer commit failed as expected: " + ex.getClass().getSimpleName());
}
showState("After REQUIRED failure");
resetData();
System.out.println();
System.out.println("--- Scenario 2: REQUIRES_NEW inner failure rolls back only the inner work ---");
checkoutService.placeOrderWithRequiresNewAudit("ORD-2002");
showState("After REQUIRES_NEW failure");
}
private void initSchema() {
jdbcTemplate.execute("drop table if exists orders");
jdbcTemplate.execute("drop table if exists audit_log");
jdbcTemplate.execute("create table orders (id varchar(50) primary key, note varchar(200))");
jdbcTemplate.execute("create table audit_log (id bigint generated by default as identity primary key, message varchar(200))");
}
private void resetData() {
jdbcTemplate.update("delete from orders");
jdbcTemplate.update("delete from audit_log");
}
private void showState(String label) {
Integer orders = jdbcTemplate.queryForObject("select count(*) from orders", Integer.class);
Integer audit = jdbcTemplate.queryForObject("select count(*) from audit_log", Integer.class);
System.out.println(label + " -> orders=" + orders + ", audit_log=" + audit);
}
}
@Service
class CheckoutService {
private final JdbcTemplate jdbcTemplate;
private final AuditService auditService;
@Autowired
CheckoutService(JdbcTemplate jdbcTemplate, AuditService auditService) {
this.jdbcTemplate = jdbcTemplate;
this.auditService = auditService;
}
@Transactional
public void placeOrderSwallowingRequiredFailure(String orderId) {
System.out.println("Checkout tx active? " + TransactionSynchronizationManager.isActualTransactionActive());
jdbcTemplate.update("insert into orders(id, note) values (?, ?)", orderId, "checkout started");
try {
// REQUIRED joins the same transaction, so a failure here poisons the whole business unit of work.
auditService.recordAuditRequiredAndFail(orderId);
} catch (RuntimeException ex) {
System.out.println("Caught inner REQUIRED exception, but the transaction is now rollback-only.");
}
}
@Transactional
public void placeOrderWithRequiresNewAudit(String orderId) {
System.out.println("Checkout tx active? " + TransactionSynchronizationManager.isActualTransactionActive());
jdbcTemplate.update("insert into orders(id, note) values (?, ?)", orderId, "checkout started");
try {
// REQUIRES_NEW suspends the outer transaction, so this failure does not cancel the order write.
auditService.recordAuditRequiresNewAndFail(orderId);
} catch (RuntimeException ex) {
System.out.println("Caught inner REQUIRES_NEW exception, outer transaction can still commit.");
}
}
}
@Service
class AuditService {
private final JdbcTemplate jdbcTemplate;
@Autowired
AuditService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional(propagation = Propagation.REQUIRED)
public void recordAuditRequiredAndFail(String orderId) {
System.out.println("Audit REQUIRED tx active? " + TransactionSynchronizationManager.isActualTransactionActive());
jdbcTemplate.update("insert into audit_log(message) values (?)", "audit for " + orderId + " via REQUIRED");
throw new IllegalStateException("simulated audit failure");
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordAuditRequiresNewAndFail(String orderId) {
System.out.println("Audit REQUIRES_NEW tx active? " + TransactionSynchronizationManager.isActualTransactionActive());
jdbcTemplate.update("insert into audit_log(message) values (?)", "audit for " + orderId + " via REQUIRES_NEW");
throw new IllegalStateException("simulated audit failure");
}
}
Follow-up & Tricky Questions:
REQUIRED. That means a method joins the current transaction if one exists, otherwise Spring starts a new one.REQUIRED different from REQUIRES_NEW? REQUIRED shares one transaction; REQUIRES_NEW suspends the old one and starts a separate transaction that can commit or roll back independently.NESTED actually do? REQUIRED, the inner method shares the same transaction, so a runtime exception can mark that transaction rollback-only. The outer method may finish normally, but commit still fails later.@Transactional sometimes seem to do nothing? REQUIRES_NEW roll back too? NESTED universally supported with JPA? Common Mistakes:
REQUIRES_NEW everywhere. Correction: it is useful for audit or outbox work, but it needs an extra connection and can hurt throughput under load.REQUIRED method and assuming the outer transaction is safe. Correction: the transaction may already be marked rollback-only, so the final commit can still fail.@Transactional methods from inside the same class and expecting Spring to apply them. Correction: use another bean or a proper proxy path.Memory Hook: Think of it like transport: REQUIRED is sharing one taxi, REQUIRES_NEW is ordering a second taxi, and NESTED is asking the driver to stop at a checkpoint so you can rewind part of the trip.
Cheat Sheet:
REQUIRED is the default.REQUIRES_NEW suspends the old transaction and starts a new one.NESTED uses a savepoint, not a separate physical transaction.REQUIRED inner failures can lead to UnexpectedRollbackException.REQUIRES_NEW is great for audit logs and outbox writes.Practice Tasks:
REQUIRES_NEW while the main order transaction rolls back.REQUIRED and observe how the final commit fails with UnexpectedRollbackException.NESTED transaction and watch how a savepoint lets one inner step roll back without losing the whole transaction.