Spring Transactions and Propagation
From the PlatformTransactionManager abstraction to propagation options, rollback rules, and the rollback-only trap.
Contents
Put the transaction boundary in the service layer, and let propagation decide whether nested calls join one transaction or split into separate ones.
Where to put the transaction boundary
A transaction groups several data changes into one unit of work. A single service-layer method usually calls several repository operations, which makes the service layer the most natural place for the boundary. If saving a member and saving a log must succeed together or be undone together, both have to sit inside the same boundary.
Where the boundary sits determines what commits together and what rolls back together. Putting it in the controller drags view logic into the transaction; putting one around each repository produces partial failures. That leaves the point where a unit of work ends, which is the service method, as the default candidate.
PlatformTransactionManager hides the technology differences
JDBC and JPA start and end transactions with different code. JDBC works directly with Connection and JPA with EntityManager, so changing the technology changes the transaction code too. Spring abstracts that difference behind the PlatformTransactionManager interface.
package org.springframework.transaction;
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}The interface exposes three operations: begin, commit, and rollback. getTransaction() may start a new transaction, or it may join one already in progress. The rule that decides which of the two happens is propagation, covered below.
Implementations exist per data access technology. JdbcTemplate and MyBatis use DataSourceTransactionManager, JPA uses JpaTransactionManager. Spring Boot detects the technology in use and registers the appropriate manager as a bean automatically, so even that choice is usually made for you.
Note that Spring 5.3 added JdbcTransactionManager (per the Spring Framework 5.3 reference). It extends DataSourceTransactionManager with additional behavior.
Declarative transactions run through a proxy
Annotating a method or class with @Transactional applies Aspect-Oriented Programming (AOP) to transaction handling. Spring registers a proxy object that handles the transaction as the bean instead of the target object, and injects that proxy at dependency injection time. Calls hit the proxy first, and the proxy starts the transaction before calling the real object.
The diagram shows the proxy wrapping transaction begin and end around the call boundary. Without an interface, the class-based CGLIB proxy is used; with an interface, a JDK dynamic proxy is used. Either way, the call has to pass through the proxy for the transaction to apply.
Physical and logical transactions
Understanding propagation requires separating two concepts. A physical transaction is the commit and rollback unit of an actual database connection. A logical transaction is the boundary created by each individual method annotated with @Transactional.
Under the default propagation, an outer method starting a transaction opens one physical transaction. An inner method called from inside it does not create a new transaction; it joins the existing one. Outer and inner are each a logical transaction, but they bind to a single physical transaction. As a result, commit and rollback actually happen only once, when the outer method (the one that created the new transaction) finishes.
The seven propagation options
Propagation is the rule for whether to join an existing transaction, create a new one, or reject the call. Spring offers the following options (per the Propagation enum in the Spring Framework reference).
| Option | No existing transaction | Existing transaction |
|---|---|---|
REQUIRED (default) | start a new transaction | join the existing one |
REQUIRES_NEW | start a new transaction | suspend the existing one and start a separate physical transaction |
SUPPORTS | proceed without a transaction | join the existing one |
NOT_SUPPORTED | proceed without a transaction | suspend the existing one and proceed without a transaction |
MANDATORY | IllegalTransactionStateException | join the existing one |
NEVER | proceed without a transaction | IllegalTransactionStateException |
NESTED | start a new transaction | create a nested transaction with a savepoint |
Most of this table is answered by REQUIRED, because grouping ordinary service calls into one unit of work is the natural thing to do. MANDATORY and NEVER come up when you want to assert whether a transaction exists, and NESTED when you need partial rollback via JDBC savepoints. Savepoints only work if the driver supports them.
The difference between REQUIRED and REQUIRES_NEW
With REQUIRED, the inner call joins the outer transaction and both share one physical transaction. With REQUIRES_NEW, the outer transaction is suspended and the inner call opens its own physical transaction. The two options handle partial failure differently.
Consider a case where a failed log write must not undo the member signup. Bound with REQUIRED, the log failure rolls back the whole physical transaction and the signup disappears with it. If an independent commit is needed, split just the log write out with REQUIRES_NEW.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Log logMessage) {
em.persist(logMessage);
}REQUIRES_NEW suspends the outer transaction while holding an additional connection. Two connections are in use at once, so frequent calls drain the connection pool quickly. Use it sparingly, only where an independent record is genuinely required, such as audit logs or an outbox table.
Rollback rules and exception types
Transaction AOP decides between commit and rollback based on the type of exception thrown out of the method. The default policy splits along the exception hierarchy.
- Unchecked exceptions (
RuntimeException,Error, and their subtypes): roll back. - Checked exceptions (
Exceptionand its subtypes): commit.
This policy assumes checked exceptions carry business meaning while unchecked exceptions represent unrecoverable errors. To change it, add exceptions to the rollback set with rollbackFor.
@Transactional(rollbackFor = Exception.class)
public void order() {
// rolls back even when a checked exception is thrown
}With rollbackFor = Exception.class, checked exceptions also trigger a rollback, and subtypes are included. Conversely, noRollbackFor excludes specific exceptions from rollback. The default policy is tied to exception type, so any different intent has to be stated explicitly through these options.
The trap that rollback-only creates
When an inner logical transaction rolls back, it cannot roll back immediately because it is not a new physical transaction. Instead it marks rollback-only on the transaction synchronization manager. Commit and rollback of the physical transaction are decided when the outer method (the one that created the new transaction) finishes.
That is where the trap appears. Even if the outer method catches the inner exception and returns normally, the transaction manager finds the rollback-only mark the moment the outer method tries to commit. The physical transaction is rolled back and UnexpectedRollbackException is thrown.
This behavior is deliberate, meant to remove ambiguity. If commit is called while a rollback has already been decided somewhere inside, raising an exception is safer than letting it pass silently. Separating the log failure from the member signup therefore takes more than catching the exception. The physical transaction itself has to be detached with REQUIRES_NEW as described above.
Self-invocation bypasses the proxy
The rule that a call must pass through the proxy has one common exception: a method calling another method on the same object, known as self-invocation. That call goes straight through the target object's this without touching the proxy, so transaction AOP never gets a chance to run.
@Service
public class OrderService {
public void external() {
internal(); // this.internal() - bypasses the proxy
}
@Transactional
public void internal() {
// the transaction may not apply here
}
}When external() calls internal() internally, @Transactional can be ignored, because the transaction-starting code never runs without the proxy. The fix is to move the method that needs a transaction into a separate bean and call it through the proxy. If splitting the class feels heavy, injecting the bean into itself and calling through that reference also works, though dividing the responsibility is usually cleaner.
Summary
Spring transactions put the boundary in the service layer and hide data access technology differences behind PlatformTransactionManager. Declarative transactions work through a proxy, so self-invocation, which bypasses the proxy, can drop the transaction entirely. Propagation decides whether to join an existing transaction or create a new one, and REQUIRED covers most cases. Reach for REQUIRES_NEW only where an independent commit is required.
Rollback happens on unchecked exceptions and commit on checked ones, so any different intent has to be declared with rollbackFor. A rollback-only mark left by an inner transaction surfaces as UnexpectedRollbackException at the outer commit. That is why handling partial failure starts with reviewing where the physical transaction boundary sits.