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.

PropagationExisting transactionNo transaction
REQUIRED (default)join itstart a new one
REQUIRES_NEWsuspend it, start a new independent onestart a new one
SUPPORTSjoin itrun without a transaction
NOT_SUPPORTEDsuspend it, run withoutrun without
MANDATORYjoin itthrow IllegalTransactionStateException
NEVERthrow exceptionrun without
NESTEDsavepoint 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.