Transaction Propagation, and What REQUIRES_NEW Really Costs
@Transactional has a propagation attribute with seven possible values. Most codebases use the default and never think about it, which is usually correct. The trouble starts when someone needs "this bit should commit even if the outer thing fails", reaches for REQUIRES_NEW, and quietly introduces a connection leak or a self-deadlock.
What propagation actually decides
Propagation answers one question: when a transactional method is called while a transaction is already running, what happens?
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
auditService.record("order placed"); // what happens here?
}
}
If record is also annotated, propagation on record decides whether it joins placeOrder's transaction, suspends it and starts its own, or refuses to run at all.
The two you need
REQUIRED is the default. If a transaction exists, join it. If not, start one. Everything shares a single physical transaction, so a rollback anywhere rolls back everything. This is the correct answer for the overwhelming majority of service methods.
REQUIRES_NEW suspends the caller's transaction, opens a second one on a second connection, commits or rolls back independently, then resumes the caller. This is what you want for an audit row or an outbox entry that must survive the failure of the work that produced it.
The other five, briefly
SUPPORTS joins a transaction if there is one and runs non-transactionally otherwise. NOT_SUPPORTED suspends any transaction and runs without one. MANDATORY throws if there is no transaction already, which is a useful assertion on an internal helper that must never be called standalone. NEVER throws if there is one. NESTED uses a JDBC savepoint so an inner failure rolls back to the savepoint without killing the outer transaction, but it needs a DataSource transaction manager and is not supported by JTA, so on JPA it is rarer than the documentation makes it sound.
Where REQUIRES_NEW hurts
Two connections are open at once for the duration of the inner call. With a HikariCP pool of ten and a request pattern that nests a new transaction inside every request, eleven concurrent requests exhaust the pool. Worse, the threads holding the outer connection are all waiting for an inner connection that no one can give them. That is a deadlock, and it shows up as Connection is not available, request timed out after 30000ms under load, never in testing.
The second trap is locking. If the outer transaction has written a row and the inner transaction tries to read it with a lock, the inner one waits for a commit that cannot happen until the inner one returns. The process blocks until the lock timeout fires.
Use REQUIRES_NEW sparingly, keep the inner method short, and make sure the pool is large enough for the nesting depth you actually have.
It only applies through the proxy
Propagation is implemented by the proxy that wraps your bean. A call from one method to another inside the same class does not pass through the proxy, so the annotation on the inner method is ignored entirely. This is the same self-invocation problem that makes @Transactional look broken, and propagation makes it easier to miss, because the code looks like it is doing something subtle and is in fact doing nothing.
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
audit("order placed"); // REQUIRES_NEW ignored, same transaction
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void audit(String message) { ... }
Move audit into its own bean and inject it.
Rollback rules are separate
A common conflation: propagation decides transaction boundaries, rollbackFor decides what triggers a rollback. By default Spring rolls back on RuntimeException and Error, and commits on checked exceptions. That surprises people who throw a checked exception expecting the work to be undone.
@Transactional(rollbackFor = Exception.class)
public void importFile(Path path) throws IOException { ... }
This distinction is one more reason unchecked exceptions tend to be the better default in a Spring service layer.
Marked rollback-only
If an inner REQUIRED method catches an exception and swallows it, the transaction is still marked rollback-only, and the outer commit fails with UnexpectedRollbackException. The fix is not to catch and ignore inside a joined transaction. If the inner work is genuinely allowed to fail on its own, that is exactly the case for REQUIRES_NEW.
A working rule
Default to REQUIRED. Reach for REQUIRES_NEW only when a write must survive the caller's rollback, put it in a separate bean, keep it short, and check your pool size. Everything else on the list is for the rare occasion when you need to assert something about the caller rather than change the behaviour.