Propagation
What Happens When a Transaction Calls Another
Propagation decides what a @Transactional method does when it is called while a transaction is already running.
| Propagation | Existing transaction | No transaction |
|---|---|---|
REQUIRED (default) | join it | start a new one |
REQUIRES_NEW | suspend it, start a new independent one | start a new one |
SUPPORTS | join it | run without a transaction |
NOT_SUPPORTED | suspend it, run without | run without |
MANDATORY | join it | throw IllegalTransactionStateException |
NEVER | throw exception | run without |
NESTED | savepoint inside it (JDBC only, not with JpaTransactionManager by default) | start a new one |
REQUIRED vs REQUIRES_NEW
ShopEase writes an audit row "order attempted" while placing an order. If the payment then fails, should the audit row survive? With REQUIRED it joins the order transaction and is rolled back with it. With REQUIRES_NEW it commits on its own.
Example
This simulation of a transaction manager keeps a stack of open transactions, exactly like Spring suspends and resumes them:
import java.util.*;
public class Main {
static final List<String> database = new ArrayList<>();
static final Deque<List<String>> txStack = new ArrayDeque<>();
public static void main(String[] args) {
for (String auditPropagation : List.of("REQUIRED", "REQUIRES_NEW")) {
database.clear();
System.out.println("=== AuditService.save uses " + auditPropagation + " ===");
try {
inTx("REQUIRED", "placeOrder", () -> {
write("order 501");
inTx(auditPropagation, "saveAudit", () -> write("audit 'order 501 attempted'"));
throw new IllegalStateException("payment declined");
});
} catch (IllegalStateException e) {
System.out.println("caller sees: " + e.getMessage());
}
System.out.println("rows in DB: " + database);
System.out.println();
}
}
static void write(String row) { txStack.peek().add(row); }
static void inTx(String propagation, String name, Runnable work) {
if (propagation.equals("REQUIRED") && !txStack.isEmpty()) {
System.out.println(name + ": participating in existing transaction");
work.run();
return;
}
System.out.println(name + ": " + (txStack.isEmpty() ? "creating new transaction"
: "suspending current transaction, creating new transaction"));
List<String> tx = new ArrayList<>();
txStack.push(tx);
try {
work.run();
database.addAll(tx);
System.out.println(name + ": commit " + tx);
} catch (RuntimeException e) {
System.out.println(name + ": rollback " + tx);
throw e;
} finally {
txStack.pop();
}
}
}Output
=== AuditService.save uses REQUIRED === placeOrder: creating new transaction saveAudit: participating in existing transaction placeOrder: rollback [order 501, audit 'order 501 attempted'] caller sees: payment declined rows in DB: [] === AuditService.save uses REQUIRES_NEW === placeOrder: creating new transaction saveAudit: suspending current transaction, creating new transaction saveAudit: commit [audit 'order 501 attempted'] placeOrder: rollback [order 501] caller sees: payment declined rows in DB: [audit 'order 501 attempted']
The Spring Code
@Service
@RequiredArgsConstructor
public class OrderService {
private final AuditService audit; // a DIFFERENT bean, so the proxy is used
@Transactional
public Order placeOrder(PlaceOrderRequest req) {
Order order = orders.save(Order.from(req));
audit.record("ORDER_ATTEMPT", order.getId());
paymentGateway.charge(order); // throws PaymentFailedException (RuntimeException)
return order;
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String event, Long orderId) {
auditRepository.save(new AuditLog(event, orderId));
}
}Log
POST /api/orders (payment declined)Example output
DEBUG o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [com.shopease.order.OrderService.placeOrder]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT DEBUG org.hibernate.SQL : insert into orders (status,total,user_id) values (?,?,?) DEBUG o.s.orm.jpa.JpaTransactionManager : Suspending current transaction, creating new transaction with name [com.shopease.audit.AuditService.record] DEBUG org.hibernate.SQL : insert into audit_logs (event,order_id) values (?,?) DEBUG o.s.orm.jpa.JpaTransactionManager : Initiating transaction commit DEBUG o.s.orm.jpa.JpaTransactionManager : Resuming suspended transaction after completion of inner transaction DEBUG o.s.orm.jpa.JpaTransactionManager : Initiating transaction rollback
Note: REQUIRES_NEW takes a second database connection while the first one is suspended. Under load, many such calls can exhaust the connection pool (HikariCP default 10). Use it only for things that truly must survive the outer rollback: audit logs, failed-attempt counters, outbox events.
UnexpectedRollbackException
A classic surprise: an inner REQUIRED method throws, the outer method catches the exception and carries on. But the inner method already marked the shared transaction as rollback-only, so the commit fails.
Example
import java.util.*;
public class Main {
static boolean rollbackOnly = false;
static final List<String> pending = new ArrayList<>();
public static void main(String[] args) {
try {
placeOrder();
} catch (RuntimeException e) {
System.out.println("controller gets: " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
static void placeOrder() { // @Transactional (REQUIRED) - starts the transaction
pending.add("order 777");
try {
applyCoupon("FREE100"); // @Transactional (REQUIRED) - joins
} catch (IllegalArgumentException e) {
System.out.println("placeOrder caught: " + e.getMessage() + " -> continue without coupon");
}
commit();
}
static void applyCoupon(String code) {
try {
throw new IllegalArgumentException("coupon " + code + " expired");
} catch (RuntimeException e) { // what the proxy of applyCoupon does
rollbackOnly = true;
System.out.println("applyCoupon proxy: marking shared transaction rollback-only");
throw e;
}
}
static void commit() {
if (rollbackOnly) {
pending.clear();
throw new UnexpectedRollbackException("Transaction silently rolled back because it has been marked as rollback-only");
}
System.out.println("committed " + pending);
}
}
class UnexpectedRollbackException extends RuntimeException {
UnexpectedRollbackException(String m) { super(m); }
}Output
applyCoupon proxy: marking shared transaction rollback-only placeOrder caught: coupon FREE100 expired -> continue without coupon controller gets: UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
Tip: Fix: validate the coupon before calling a transactional method (return a result instead of throwing), or make applyCoupon not transactional, or use noRollbackFor on it.