What this post covers
This is the seventeenth post in the "Digging into Spring's Source" series. With Part 16 we finished Phase 1 (the container), Phase 2 (AOP) and Phase 3 (foundations) of the map in Part 1. This post opens Phase 4 (transactions and data access). Its first unit, U15, is this post, and it is also where the proxies of Part 10 and Part 11 finally get put to work.
You learn early on that a service method marked @Transactional is committed when it returns and rolled back when it throws. Yet the method itself contains neither a commit nor a rollback. There are four questions to answer.
- Which interfaces abstract "opening and closing" a transaction in the source, and where do the attributes of
@Transactionalend up? - What does one line of
@EnableTransactionManagementregister in the container so that@Transactionalmethods get wrapped in a proxy, and when that proxy is called, who opens and closes the transaction? - What happens when a transactional method is called from inside another one (propagation), and what mechanism keeps one connection shared across several methods?
- Why does throwing a checked exception not roll back, and why does the outer method get an
UnexpectedRollbackExceptioneven though it caught and swallowed the inner method's exception?
| Item | Details |
|---|---|
| Source | Spring Framework 7.1.0-SNAPSHOT |
| Commit | f447f3c310 (2026-10-01, main branch) |
| Modules read most | spring-tx (transaction, transaction.support, transaction.interceptor, transaction.annotation), spring-jdbc (jdbc.datasource) |
| Classes read most | PlatformTransactionManager, TransactionDefinition, TransactionStatus, AbstractPlatformTransactionManager, TransactionSynchronizationManager, TransactionInterceptor, TransactionAspectSupport, AnnotationTransactionAttributeSource, DataSourceTransactionManager, DataSourceUtils |
Every line number and every detail in this post refers to that commit. In other versions the line numbers will differ and some details may too. Part 1 explains how to get the source at the same commit.
The original study notes go much deeper, line by line, but this post keeps only the main thread that someone new to Spring can follow. What was left out is listed by name at the end, under "Going deeper".
Words to know first
A transaction is "a group of database operations treated as one unit: either all of them are applied (commit) or all of them are undone (rollback)". It is what stops a bank transfer from debiting one account without crediting the other. In JDBC one Connection object carries one transaction, and con.commit() or con.rollback() ends it.
Auto-commit is the default state of a JDBC connection: every single SQL statement is committed immediately. To group several statements into one transaction you switch it off with con.setAutoCommit(false) and commit yourself later. Half of what Spring does in this post is this switching on and off.
A DataSource is a factory for connections. dataSource.getConnection() gives you a new connection (or one taken from a pool). The examples in this post use the simplest, pool-less DriverManagerDataSource.
Propagation is the rule for "what to do when a transactional method is called while a transaction is already open". It can join the existing one or set it aside and open a new one. There are seven options in the source, and section 1 tabulates them.
Proxy, advice and advisor are the words from Part 10 and Part 11. A proxy is a stand-in object wrapping the original and stepping in before and after a method call; the advice is the code that steps in; an advisor bundles "where" (the pointcut) with "what" (the advice). The TransactionInterceptor in this post is an advice.
A ThreadLocal is "a variable with a separate value per thread". Even though it is one static field, a value set by thread A is visible only to thread A, never to thread B. This is the secret behind Spring letting every JDBC call in a method use the same connection without passing it as an argument (section 5).
1. Three interfaces: open it, how to open it, what state it is in
At the top of the org.springframework.transaction package in spring-tx sits TransactionManager, a marker interface with no methods at all (TransactionManager line 28). Below it the tree splits into the classic (blocking) PlatformTransactionManager and the reactive ReactiveTransactionManager (line 36). The reactive side waits for Part 19; this post looks only at PlatformTransactionManager.
PlatformTransactionManager: three methods to open and close
public interface PlatformTransactionManager extends TransactionManager {
// ...
TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
// ...
void commit(TransactionStatus status) throws TransactionException;
// ...
void rollback(TransactionStatus status) throws TransactionException;
}
spring-tx/src/main/java/org/springframework/transaction/PlatformTransactionManager.java lines 47–120
Only three methods. You give getTransaction a description of "how to open it" (TransactionDefinition), it returns "what state it is in" (TransactionStatus), and you pass that status to commit or rollback to close it. The interface's Javadoc says applications are not expected to call it directly but to use TransactionTemplate or declarative transactions through AOP (lines 22–25). @Transactional, the subject of this post, is the "through AOP" route.
There is one implementation per data access technology: DataSourceTransactionManager for JDBC (spring-jdbc line 122) and its subclass JdbcTransactionManager (line 54), JtaTransactionManager for JTA (line 116), and the JPA one that Part 19 covers. All of them extend AbstractPlatformTransactionManager (lines 89–90), the main character of sections 4 and 6.
How the three interfaces fit together, and the family tree of implementations. Every technology-specific implementation goes through AbstractPlatformTransactionManager. Grey boxes are the definition and the status; yellow boxes are the two layers this post reads.
TransactionDefinition: how to open it
TransactionDefinition is an interface made of constants and default methods. The constants are the seven propagation options (lines 52–132), five isolation levels (lines 140–184; the comments note the values equal the java.sql.Connection constants) and the default timeout -1 (line 191). Five methods return the defaults.
| Method | Default | Where |
|---|---|---|
getPropagationBehavior() |
PROPAGATION_REQUIRED |
lines 203–205 |
getIsolationLevel() |
ISOLATION_DEFAULT (-1, whatever the database uses) |
lines 225–227 |
getTimeout() |
TIMEOUT_DEFAULT (-1) |
lines 240–242 |
isReadOnly() |
false |
lines 261–263 |
getName() |
null |
line 275 |
The attributes of the @Transactional annotation map onto the first four of these one by one (the fifth, the name, is not an annotation attribute; the interceptor fills it in, as section 3 shows): propagation defaulting to Propagation.REQUIRED (Transactional line 178), isolation (line 192), timeout (line 203) and readOnly (line 229). Each value of the Propagation enum simply carries the constant above (Propagation lines 37–97). Of the things the annotation has and the definition does not, the ones that matter in this post are rollbackFor and noRollbackFor; section 3 covers them.
Section 4 confirms the seven propagation options in code, but here is what they mean, up front.
| Propagation | No transaction yet | Transaction already open |
|---|---|---|
REQUIRED (default) |
open a new one | join it |
SUPPORTS |
run without one | join it |
MANDATORY |
exception | join it |
REQUIRES_NEW |
open a new one | set the existing one aside (suspend) and open a new one |
NOT_SUPPORTED |
run without one | set the existing one aside and run without one |
NEVER |
run without one | exception |
NESTED |
open a new one | create a savepoint inside the existing one |
TransactionStatus: what state it is in
TransactionStatus combines TransactionExecution, SavepointManager and Flushable (line 40). This post uses four methods from the TransactionExecution side: isNewTransaction(), "did I open this transaction myself?" (line 73); setRollbackOnly(), which schedules a rollback without throwing (line 106); isRollbackOnly() (line 116); and isCompleted() (line 125). The implementation is DefaultTransactionStatus (line 52), and whether isNewTransaction() is true or false decides the fate of commit and rollback in section 6.
2. What one line of @EnableTransactionManagement registers
The shape is the same as @EnableAspectJAutoProxy in Part 11. Open @EnableTransactionManagement and you find a single @Import(TransactionManagementConfigurationSelector.class) (EnableTransactionManagement lines 159–163) plus the attributes proxyTargetClass (line 182), mode (default PROXY, line 194), order (line 201) and rollbackOn, added in 6.2 (line 222). As Part 9 showed, giving @Import an ImportSelector lets that class return, by name, which further configuration classes to read.
@Override
protected String[] selectImports(AdviceMode adviceMode) {
return switch (adviceMode) {
case PROXY -> new String[] {AutoProxyRegistrar.class.getName(),
ProxyTransactionManagementConfiguration.class.getName()};
case ASPECTJ -> new String[] {determineTransactionAspectClass()};
};
}
Blue boxes are what gets registered, the green box is the result. The advisor bundles the pointcut with the interceptor.
In PROXY mode two names come back.
AutoProxyRegistrarcalls theAopConfigUtils.registerAutoProxyCreatorIfNecessaryseen in Part 11 to register the auto-proxy creatorInfrastructureAdvisorAutoProxyCreator(AutoProxyRegistrarlines 72–73). As section 1 of Part 11 explained, a container has exactly one auto-proxy creator, so if@EnableAspectJAutoProxyis also present it is promoted to the more capable one. Either way the behaviour is the same: in every bean's after-initialisation hook, wrap it if an advisor matches.ProxyTransactionManagementConfigurationis the@Configurationclass that registers the advisor and its parts.
@Bean(name = TransactionManagementConfigUtils.TRANSACTION_ADVISOR_BEAN_NAME)
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public BeanFactoryTransactionAttributeSourceAdvisor transactionAdvisor(
TransactionAttributeSource transactionAttributeSource, TransactionInterceptor transactionInterceptor) {
BeanFactoryTransactionAttributeSourceAdvisor advisor = new BeanFactoryTransactionAttributeSourceAdvisor();
advisor.setTransactionAttributeSource(transactionAttributeSource);
advisor.setAdvice(transactionInterceptor);
if (this.enableTx != null) {
advisor.setOrder(this.enableTx.<Integer>getNumber("order"));
}
return advisor;
}
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public TransactionInterceptor transactionInterceptor(TransactionAttributeSource transactionAttributeSource) {
TransactionInterceptor interceptor = new TransactionInterceptor();
interceptor.setTransactionAttributeSource(transactionAttributeSource);
if (this.txManager != null) {
interceptor.setTransactionManager(this.txManager);
}
return interceptor;
}
This class has only two @Bean methods. The third part, transactionAttributeSource, lives in the parent class AbstractTransactionManagementConfiguration. The parent has one more @Bean, the TransactionalEventListenerFactory behind the @TransactionalEventListener that "Going deeper" only names (lines 94–98); this post does not cover it.
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public TransactionAttributeSource transactionAttributeSource() {
// Accept protected @Transactional methods on CGLIB proxies, as of 6.0
AnnotationTransactionAttributeSource tas = new AnnotationTransactionAttributeSource(false);
// Apply default rollback rule, as of 6.2
if (this.enableTx != null && this.enableTx.getEnum("rollbackOn") == RollbackOn.ALL_EXCEPTIONS) {
tas.addDefaultRollbackRule(RollbackRuleAttribute.ROLLBACK_ON_ALL_EXCEPTIONS);
}
return tas;
}
The roles of the three beans, in one table.
| Bean | Class | Role |
|---|---|---|
transactionAttributeSource |
AnnotationTransactionAttributeSource |
reads @Transactional from methods and classes and turns it into a TransactionAttribute |
transactionInterceptor |
TransactionInterceptor |
the advice: opens before the call, closes after it (section 3) |
transactionAdvisor |
BeanFactoryTransactionAttributeSourceAdvisor |
pointcut + the interceptor above; the auto-proxy creator looks at this advisor to decide whether to wrap |
The advisor's pointcut is a TransactionAttributeSourcePointcut (BeanFactoryTransactionAttributeSourceAdvisor lines 34–36), and its matches boils down to "does the attribute source have a transaction attribute for this method?" (lines 54–57). Reading @Transactional and deciding whether to wrap come from the same object. The class filter first excludes infrastructure such as TransactionManager implementations, then uses isCandidateClass for a quick "could this class carry the annotation at all?" check (lines 83–90).
The side that reads @Transactional
AnnotationTransactionAttributeSource holds a list of parsers; Spring's own @Transactional is handled by SpringTransactionAnnotationParser (lines 86–98; if jakarta.transaction.Transactional is on the classpath a JTA parser is added too, lines 89–91).
protected TransactionAttribute parseTransactionAnnotation(AnnotationAttributes attributes) {
RuleBasedTransactionAttribute rbta = new RuleBasedTransactionAttribute();
Propagation propagation = attributes.getEnum("propagation");
rbta.setPropagationBehavior(propagation.value());
Isolation isolation = attributes.getEnum("isolation");
rbta.setIsolationLevel(isolation.value());
rbta.setTimeout(attributes.getNumber("timeout").intValue());
// ...
rbta.setReadOnly(attributes.getBoolean("readOnly"));
rbta.setQualifier(attributes.getString("value"));
rbta.setLabels(Set.of(attributes.getStringArray("label")));
List<RollbackRuleAttribute> rollbackRules = new ArrayList<>();
for (Class<?> rbRule : attributes.getClassArray("rollbackFor")) {
rollbackRules.add(new RollbackRuleAttribute(rbRule));
}
// ...
for (Class<?> rbRule : attributes.getClassArray("noRollbackFor")) {
rollbackRules.add(new NoRollbackRuleAttribute(rbRule));
}
// ...
rbta.setRollbackRules(rollbackRules);
return rbta;
}
The annotation attributes are read with AnnotatedElementUtils.findMergedAnnotationAttributes from Part 13 (lines 56–57). The result, a RuleBasedTransactionAttribute, extends DefaultTransactionAttribute, which extends DefaultTransactionDefinition (line 39, line 40), so it is also the TransactionDefinition of section 1. What it adds is the list of rollback rules built from rollbackFor and noRollbackFor, the raw material for the rollbackOn(Throwable) method that the TransactionAttribute interface adds on top of TransactionDefinition (TransactionAttribute line 37, line 60).
Which method's annotation to read is decided by the parent, AbstractFallbackTransactionAttributeSource. Results are cached per method and class (lines 116–140); the first time, computeTransactionAttribute goes looking.
protected @Nullable TransactionAttribute computeTransactionAttribute(Method method, @Nullable Class<?> targetClass) {
// Don't allow non-public methods, as configured.
if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
return null;
}
// ...
Method specificMethod = AopUtils.getMostSpecificMethod(method, targetClass);
// First try is the method in the target class.
TransactionAttribute txAttr = findTransactionAttribute(specificMethod);
if (txAttr != null) {
return txAttr;
}
// Second try is the transaction attribute on the target class.
txAttr = findTransactionAttribute(specificMethod.getDeclaringClass());
if (txAttr != null && ClassUtils.isUserLevelMethod(method)) {
return txAttr;
}
// ...
return null;
}
The order is: the method on the target class, then the target class itself, then (if the call came through an interface method) the original method, then its declaring class (lines 187–198). The second try is why a class-level @Transactional acts as the default for its methods.
Easy to get wrong: methods that are not public
- In this version,
@Transactionalonprotectedand package-private methods is read.allowPublicMethodsOnly()returns thepublicMethodsOnlyfield, whose default istrue(line 75, lines 215–217), but as shown above@EnableTransactionManagementbuilds it withnew AnnotationTransactionAttributeSource(false). The comment says protected@Transactionalmethods on CGLIB proxies are accepted as of 6.0. privatemethods still do not work. This is not about reading the attribute but about the proxy. As Part 10 showed, a CGLIB proxy is a subclass, so it cannot overrideprivateorfinalmethods, and aprivatemethod can only be called from inside its own class, that is, throughthis. That is the self-invocation case of the next section.Transactional's Javadoc only says to consult the reference manual for method visibility constraints (lines 37–39).- A class-level annotation is the default only for that class and its subclasses. The Javadoc states that it does not apply to methods inherited from ancestor classes (lines 33–36).
3. At call time: TransactionInterceptor opens and closes
Now the title question can be answered. When a method is called on the proxy, the advice chain of section 5 in Part 11 reaches TransactionInterceptor.invoke. TransactionInterceptor is a MethodInterceptor (lines 58–59), and invoke hands the real work to its parent, TransactionAspectSupport.
@Override
public @Nullable Object invoke(MethodInvocation invocation) throws Throwable {
// ...
Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null);
// Adapt to TransactionAspectSupport's invokeWithinTransaction...
return invokeWithinTransaction(invocation.getMethod(), targetClass, new InvocationCallback() {
@Override
public @Nullable Object proceedWithInvocation() throws Throwable {
return invocation.proceed();
}
// ...
The yellow box binds to the ThreadLocal; the red boxes are the two branches after an exception. A checked exception takes the right branch, which commits.
Here is the skeleton of invokeWithinTransaction. The reactive branch at the top (lines 341–358, Part 19) and the handling of Future and Vavr return values (lines 382–406) are cut.
protected @Nullable Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
final InvocationCallback invocation) throws Throwable {
// If the transaction attribute is null, the method is non-transactional.
TransactionAttributeSource tas = getTransactionAttributeSource();
final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
final TransactionManager tm = determineTransactionManager(txAttr, targetClass);
// ...
PlatformTransactionManager ptm = asPlatformTransactionManager(tm);
final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);
if (txAttr == null || !(ptm instanceof CallbackPreferringPlatformTransactionManager cpptm)) {
// Standard transaction demarcation with getTransaction and commit/rollback calls.
TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);
Object retVal;
try {
// This is an around advice: Invoke the next interceptor in the chain.
// This will normally result in a target object being invoked.
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
// target invocation exception
completeTransactionAfterThrowing(txInfo, invocation, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
// ...
commitTransactionAfterReturning(txInfo);
return retVal;
}
// ...
- Read the annotation. This is
getTransactionAttributefrom section 2. Thanks to the cache, every call after the first is a map lookup. - Pick the transaction manager.
determineTransactionManageruses the annotation'svalue/transactionManagerqualifier if there is one, otherwise a class-level@Qualifier, otherwise the configuredtransactionManagerBeanName, otherwise a manager set directly on the interceptor (thetxManagerin the section 2 code), and finally the single bean of typeTransactionManagerin the container, which it caches (lines 489–534; the lookup by type is line 527). That is why the example'sH2Configdeclares exactly onePlatformTransactionManagerbean. - Open.
createTransactionIfNecessarycallstm.getTransaction(txAttr)(line 628). Just before that, if the annotation gave no name, "class name.method name" becomes the transaction name (lines 616–623). That is whygetCurrentTransactionName()printsex.u15.…MemberService.reportin the example output below. - Run the original method.
proceedWithInvocation()is the callback frominvokeabove, that is,invocation.proceed(). - Close. On a normal return
commitTransactionAfterReturningcallstm.commit(status)(lines 682–689); on an exceptioncompleteTransactionAfterThrowingcalls one of two things.
if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) {
invocation.onRollback(ex, txInfo.getTransactionStatus());
try {
txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
}
// ...
}
else {
// We don't roll back on this exception.
// Will still roll back if TransactionStatus.isRollbackOnly() is true.
try {
txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
}
// ...
}
So the answer to "who opens a transaction and who closes it" is this: TransactionInterceptor (strictly, TransactionAspectSupport) both opens and closes it, and delegates the actual work to the PlatformTransactionManager. The service method itself knows nothing about either.
The rule for rolling back: rollbackOn
This is the fork rollbackOn(ex) in the code above. With no rules at all, the default is in DefaultTransactionAttribute.
@Override
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}
Only RuntimeException and Error roll back; checked exceptions commit. The comment right above explains that a checked exception is treated as a business exception, "a kind of alternative return value" (lines 170–176). Once you write rollbackFor or noRollbackFor, RuleBasedTransactionAttribute consults the rules first.
@Override
public boolean rollbackOn(Throwable ex) {
RollbackRuleAttribute winner = null;
int deepest = Integer.MAX_VALUE;
if (this.rollbackRules != null) {
for (RollbackRuleAttribute rule : this.rollbackRules) {
int depth = rule.getDepth(ex);
if (depth >= 0 && depth < deepest) {
deepest = depth;
winner = rule;
}
}
}
// User superclass behavior (rollback on unchecked) if no rule matches.
if (winner == null) {
return super.rollbackOn(ex);
}
return !(winner instanceof NoRollbackRuleAttribute);
}
getDepth walks up from the thrown exception's class towards its parents and returns how many steps it took to reach the rule's exception type, or -1 if it reaches Throwable without a match (lines 165–186). The rule closest in the exception hierarchy wins; if that rule is a noRollbackFor one the transaction commits, otherwise it rolls back. If no rule matches at all, the default rule applies.
The path that decides whether an exception ends in a commit or a rollback. The row counts in the bottom boxes are the real output of Example 1.
Example 1: how many rows survive each kind of exception
An H2 in-memory database gets a member table, and a @Transactional method inserts two rows and then throws. The three methods differ only in the exception thrown and the annotation attributes.
// An example written for this post (not Spring's code)
@Transactional
public void saveTwoThenRuntimeException() {
jdbc.update("INSERT INTO member (name) VALUES (?)", "a");
jdbc.update("INSERT INTO member (name) VALUES (?)", "b");
throw new IllegalStateException("runtime exception");
}
@Transactional
public void saveTwoThenCheckedException() throws Exception {
jdbc.update("INSERT INTO member (name) VALUES (?)", "a");
jdbc.update("INSERT INTO member (name) VALUES (?)", "b");
throw new Exception("checked exception");
}
@Transactional(rollbackFor = Exception.class)
public void saveTwoThenCheckedExceptionWithRollbackFor() throws Exception {
jdbc.update("INSERT INTO member (name) VALUES (?)", "a");
jdbc.update("INSERT INTO member (name) VALUES (?)", "b");
throw new Exception("checked exception (rollbackFor = Exception.class)");
}
This example was run on the released Spring Framework 7.0.9 and produced the output below (it was not run against a self-built 7.1.0-SNAPSHOT, the version this series reads). The labels in the output are translated from the Korean original.
== Proxy
class of the bean = ex.u15.RollbackRuleExample$MemberService$$SpringCGLIB$$0
is a CGLIB proxy = true
== Outside a transactional method
[outside] isActualTransactionActive = false
[outside] getCurrentTransactionName = null
== Inside a transactional method (report)
[inside] isActualTransactionActive = true
[inside] getCurrentTransactionName = ex.u15.RollbackRuleExample$MemberService.report
[inside] isSynchronizationActive = true
[inside] currentTransactionStatus().isNewTransaction() = true
== Throwing a RuntimeException
exception: IllegalStateException - runtime exception
rows left = 0
== Throwing a checked exception (Exception)
exception: Exception - checked exception
rows left = 2
== Checked exception + rollbackFor = Exception.class
exception: Exception - checked exception (rollbackFor = Exception.class)
rows left = 0
The bean has no interface, so by Part 10 it is a CGLIB proxy, and a transaction is reported as "present" only inside the method (isActualTransactionActive is explained in section 5). The last three blocks are the point. IllegalStateException rolls back to 0 rows, Exception commits and leaves 2 rows, and adding rollbackFor = Exception.class brings it back to 0.
Easy to get wrong
- Checked exceptions do not roll back by default. Exactly as the output shows. Instead of adding
rollbackForto every method you can switch it once with@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS); as section 2 showed, theROLLBACK_ON_ALL_EXCEPTIONSrule is then appended to every attribute (AnnotationTransactionAttributeSourcelines 201–203). The Javadoc ofEnableTransactionManagementrecommends the switch especially for Kotlin, which does not enforce checked exceptions (lines 211–215). - If you catch the exception inside the method, the interceptor never sees it. The interceptor only sees exceptions that leave the method. To catch and still roll back, call
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()(lines 163–169). The comment "Will still roll back if TransactionStatus.isRollbackOnly() is true" in the code above is about that case. - Self-invocation gets no transaction. It is the rule from section 6 of Part 10 and section 6 of Part 11: the advice lives only in the proxy, and
this.inner()never goes through the proxy. The Javadoc ofEnableTransactionManagementsays the same: local calls within the same class cannot be intercepted, and a@Transactionalon such a method is ignored (lines 187–192).
Example 2: this.inner() and this.innerRequiresNew()
A non-transactional outer() calls @Transactional inner() through this, and @Transactional outerTx() calls @Transactional(REQUIRES_NEW) innerRequiresNew() through this. The same methods called directly from outside are shown next to each.
== Calling outer() from outside → this.inner() inside
[outer] class of this = NoticeService
[inner] isActualTransactionActive = false
== Calling inner() directly from outside
[inner] isActualTransactionActive = true
== Calling outerTx() from outside → this.innerRequiresNew() inside
[outerTx] transaction name = ex.u15.SelfInvocationExample$NoticeService.outerTx
[innerRequiresNew] transaction name = ex.u15.SelfInvocationExample$NoticeService.outerTx
== Calling innerRequiresNew() directly from outside
[innerRequiresNew] transaction name = ex.u15.SelfInvocationExample$NoticeService.innerRequiresNew
this prints as the plain NoticeService, not the proxy, and the inner() called from inside has no transaction. The second pair is subtler. this.innerRequiresNew() runs without any error, but inside the outer outerTx transaction, not a new one. This is the most common reason REQUIRES_NEW "does not work".
4. getTransaction: template methods and propagation
Into tm.getTransaction(txAttr), the call the interceptor made. AbstractPlatformTransactionManager implements propagation, synchronization and the commit flow in full and leaves only the parts that touch the actual resource (the connection) as empty doXxx template methods. Four of them are abstract and every subclass must provide them: doGetTransaction (line 1099), doBegin (line 1159), doCommit (line 1253) and doRollback (line 1264). isExistingTransaction (default false, lines 1116–1118), doSuspend, doResume, doSetRollbackOnly and doCleanupAfterCompletion are optional.
@Override
public final TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
throws TransactionException {
// Use defaults if no transaction definition given.
TransactionDefinition def = (definition != null ? definition : TransactionDefinition.withDefaults());
Object transaction = doGetTransaction();
boolean debugEnabled = logger.isDebugEnabled();
if (isExistingTransaction(transaction)) {
// Existing transaction found -> check propagation behavior to find out how to behave.
return handleExistingTransaction(def, transaction, debugEnabled);
}
// ...
// No existing transaction found -> check propagation behavior to find out how to proceed.
if (def.getPropagationBehavior() == TransactionDefinition.PROPAGATION_MANDATORY) {
throw new IllegalTransactionStateException(
"No existing transaction found for transaction marked with propagation 'mandatory'");
}
else if (def.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRED ||
def.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRES_NEW ||
def.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NESTED) {
SuspendedResourcesHolder suspendedResources = suspend(null);
// ...
try {
return startTransaction(def, transaction, false, debugEnabled, suspendedResources);
}
// ...
}
else {
// Create "empty" transaction: no actual transaction, but potentially synchronization.
// ...
boolean newSynchronization = (getTransactionSynchronization() == SYNCHRONIZATION_ALWAYS);
return prepareTransactionStatus(def, null, true, newSynchronization, debugEnabled, null);
}
}
Left: no transaction yet. Right: one already open. Only the green boxes have isNewTransaction set to true.
The flow has three parts.
doGetTransaction(): the subclass builds an object describing "this thread's current transaction state".DataSourceTransactionManagerlooks up theConnectionHolderbound to the thread throughTransactionSynchronizationManager.getResource(dataSource)(section 5) and puts it in (lines 247–254).isExistingTransaction(): if that holder exists andisTransactionActive(), we are "already inside a transaction" (lines 257–260) and control goes tohandleExistingTransaction.- The propagation branch when there is none:
MANDATORYthrows,REQUIRED,REQUIRES_NEWandNESTEDcallstartTransaction, and the other three build a status object without a real transaction.
startTransaction creates the status with newTransaction = true, calls doBegin, and then prepareSynchronization writes the transaction name, the read-only flag and the "actual transaction active" marker into the thread locals (lines 524–541, lines 575–585). The values that isActualTransactionActive and getCurrentTransactionName had only inside the method in example 1 were written here.
When a transaction already exists: handleExistingTransaction
handleExistingTransaction (lines 426–519) checks the propagation options one after another, top to bottom.
| Propagation | What happens | Where |
|---|---|---|
NEVER |
IllegalTransactionStateException |
lines 430–433 |
NOT_SUPPORTED |
suspend(transaction), then a status object with no transaction |
lines 435–443 |
REQUIRES_NEW |
suspend(transaction), then startTransaction; the suspended resources ride along in the status object |
lines 445–458 |
NESTED |
exception unless nestedTransactionAllowed; otherwise a savepoint (createAndHoldSavepoint) |
lines 460–492 |
REQUIRED, SUPPORTS, MANDATORY |
participate in the existing transaction: a status object with newTransaction = false |
lines 494–518 |
suspend detaches the synchronization list, detaches the resource through doSuspend, clears the thread-local name, read-only and active markers, and packs all of it into one SuspendedResourcesHolder that it returns (lines 614–648). DataSourceTransactionManager.doSuspend is an unbindResource that takes the ConnectionHolder off the thread (lines 324–328), which is why the doBegin that follows opens a new connection. When the inner transaction finishes, cleanupAfterCompletion puts everything back with resume (lines 1063–1069).
Easy to get wrong
NESTEDneeds the manager's permission. The default ofnestedTransactionAllowedisfalse(line 134), butDataSourceTransactionManagerturns it on in its constructor (lines 137–138) because JDBC has savepoints.- Running "without a transaction" under
SUPPORTS,NOT_SUPPORTEDorNEVERstill creates a status object. That is the "empty" transaction in the code above. Synchronization (section 5) may be switched on, butisActualTransactionActive()isfalse. Specifying an isolation level here logs a warning that it will be ignored (lines 414–417). - When participating, isolation level and read-only are not checked by default. Only with
validateExistingTransactionset totruedoes the inner definition get compared with the outer transaction and an exception thrown (line 136, lines 499–516). By default the innerisolationis silently ignored.
5. A connection bound to the thread: doBegin and TransactionSynchronizationManager
The doBegin that startTransaction called is where the JDBC connection is actually opened.
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction;
Connection con = null;
try {
if (!txObject.hasConnectionHolder() ||
txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
Connection newCon = obtainDataSource().getConnection();
// ...
txObject.setConnectionHolder(new ConnectionHolder(newCon), true);
}
txObject.getConnectionHolder().setSynchronizedWithTransaction(true);
con = txObject.getConnectionHolder().getConnection();
Integer previousIsolationLevel = DataSourceUtils.prepareConnectionForTransaction(con,
definition.getIsolationLevel(),
(definition.isReadOnly() && !isDefaultReadOnly()));
// ...
if (con.getAutoCommit()) {
txObject.setMustRestoreAutoCommit(true);
// ...
con.setAutoCommit(false);
}
prepareTransactionalConnection(con, definition);
txObject.getConnectionHolder().setTransactionActive(true);
// ...
// Bind the connection holder to the thread.
if (txObject.isNewConnectionHolder()) {
TransactionSynchronizationManager.bindResource(obtainDataSource(), txObject.getConnectionHolder());
}
}
// ...
}
It does four things: takes a connection from the DataSource and wraps it in a ConnectionHolder, applies isolation level and read-only to the connection, switches auto-commit off, and binds the holder to the thread. The bindResource on the last line is the heart of this section.
The yellow box is the ThreadLocal map. Left: who puts it in. Middle: who takes it out and uses it. Right: who cleans up.
TransactionSynchronizationManager is a class of static methods only, and all its state is ThreadLocal.
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<>("Transactional resources");
private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
new NamedThreadLocal<>("Transaction synchronizations");
private static final ThreadLocal<String> currentTransactionName =
new NamedThreadLocal<>("Current transaction name");
private static final ThreadLocal<Boolean> currentTransactionReadOnly =
new NamedThreadLocal<>("Current transaction read-only status");
private static final ThreadLocal<Integer> currentTransactionIsolationLevel =
new NamedThreadLocal<>("Current transaction isolation level");
private static final ThreadLocal<Boolean> actualTransactionActive =
new NamedThreadLocal<>("Actual transaction active");
| Field | Holds | Written by |
|---|---|---|
resources |
a map from resource factory to resource holder; for JDBC, DataSource → ConnectionHolder |
bindResource (lines 170–177) |
synchronizations |
the set of callbacks to run at commit and completion; its presence means "synchronization active" | initSynchronization (lines 316–321) |
currentTransactionName and the next two |
name, read-only flag, isolation level | prepareSynchronization from section 4 |
actualTransactionActive |
whether a real transaction exists; isActualTransactionActive() only checks for non-null (lines 499–501) |
the same place |
bindResource puts the value into the map but throws IllegalStateException if the key is already there (lines 173–176); getResource takes it out (lines 133–136). A ConnectionHolder is a small object holding one connection and a "JDBC transaction active" flag (lines 43–55); its parent ResourceHolderSupport carries the rollbackOnly marker that matters in section 6, and a reference count (line 41, line 45).
The side that takes it out: DataSourceUtils.getConnection
Binding alone achieves nothing. The JdbcTemplate inside the service method (the subject of Part 18) has to find and use that same connection to be part of the transaction. The route is DataSourceUtils.getConnection (lines 79–89).
public static Connection doGetConnection(DataSource dataSource) throws SQLException {
Assert.notNull(dataSource, "No DataSource specified");
ConnectionHolder conHolder = (ConnectionHolder) TransactionSynchronizationManager.getResource(dataSource);
if (conHolder != null && (conHolder.hasConnection() || conHolder.isSynchronizedWithTransaction())) {
conHolder.requested();
// ...
return conHolder.getConnection();
}
// Else we either got no holder or an empty thread-bound holder here.
logger.debug("Fetching JDBC Connection from DataSource");
Connection con = fetchConnection(dataSource);
if (TransactionSynchronizationManager.isSynchronizationActive()) {
// ...
TransactionSynchronizationManager.bindResource(dataSource, holderToUse);
// ...
}
return con;
}
spring-jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java lines 103–147
If a holder is bound to the thread, its connection is returned and only the reference count goes up. The matching releaseConnection does not close a bound connection; it only decrements the count (lines 407–420, "It's the transactional Connection: Don't close it."). If nothing is bound, a fresh connection is fetched, and if synchronization is active (for instance when running under SUPPORTS without a transaction) it is bound on the spot so it is reused until the method ends.
The JdbcTemplate of Part 18 calls DataSourceUtils.getConnection(obtainDataSource()) in each execute method that needs a connection, before invoking the callback, and releaseConnection when it is done (in the first execute, JdbcTemplate line 362 and line 377 in the finally). So however many times you call jdbcTemplate.update() inside a @Transactional method, and from however many different beans, without ever passing a connection as an argument, all of them use the connection that doBegin opened. The example output in section 6, where the outer and inner methods both print conn7, is the proof.
When the transaction ends, doCleanupAfterCompletion undoes it in reverse: unbinds the holder from the thread (unbindResource), restores auto-commit and returns the connection (lines 376–405).
Easy to get wrong
- Other threads know nothing about the transaction. Everything is
ThreadLocal, so work handed to a new thread or aCompletableFuturefrom inside a@Transactionalmethod cannot find the bound connection and gets its own. Rolling back the outer transaction does not undo what that thread did. - Calling
dataSource.getConnection()directly puts you outside the transaction. The one that consults the map isDataSourceUtils. A connection taken straight from theDataSourceis a different connection from the bound one, and it is in auto-commit mode. readOnlyis a hint. The Javadoc ofPlatformTransactionManagersays the read-only flag is "just a hint for potential optimization" and should be ignored if not supported (lines 57–59). For JDBC,con.setReadOnly(true)is called, but a failure is swallowed unless it is a timeout (DataSourceUtilslines 232–236). Whether writes are actually blocked depends on the driver and the database.
6. commit and rollback, and UnexpectedRollbackException
The commit that the interceptor calls at the end.
@Override
public final void commit(TransactionStatus status) throws TransactionException {
if (status.isCompleted()) {
throw new IllegalTransactionStateException(
"Transaction is already completed - do not call commit or rollback more than once per transaction");
}
DefaultTransactionStatus defStatus = (DefaultTransactionStatus) status;
if (defStatus.isLocalRollbackOnly()) {
// ...
processRollback(defStatus, false);
return;
}
if (!shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly()) {
// ...
processRollback(defStatus, true);
return;
}
processCommit(defStatus);
}
It is called commit, yet there are two roads to a rollback. One is local rollback-only: somebody called setRollbackOnly() on this TransactionStatus (AbstractTransactionStatus lines 62–67, lines 86–88). The other is global rollback-only: the transaction object itself carries a rollback marker. DefaultTransactionStatus.isGlobalRollbackOnly asks the transaction object's isRollbackOnly() if it is a SmartTransactionObject (lines 175–178), and the JDBC transaction object answers with ConnectionHolder.isRollbackOnly() (JdbcTransactionObjectSupport line 48, DataSourceTransactionManager lines 512–515). shouldCommitOnGlobalRollbackOnly() defaults to false (lines 1227–1229). Who sets that marker, and when, is the puzzle of this section.
The happy path, processCommit (lines 766–848), is all about order: triggerBeforeCommit → triggerBeforeCompletion (lines 774–775) → release the savepoint if there is one, doCommit if this is a new transaction, nothing at all if merely participating (lines 778–798) → triggerAfterCommit → triggerAfterCompletion (lines 835–838) → cleanupAfterCompletion in a finally (line 846). The trigger… calls run the callbacks registered in the synchronizations set from section 5 (TransactionSynchronization lines 133–186), and for JDBC doCommit is a single con.commit() (lines 336–348). cleanupAfterCompletion marks the status completed, clears the thread locals if this transaction opened the synchronization (TransactionSynchronizationManager.clear), calls the doCleanupAfterCompletion from the end of section 5 if it was a new transaction, and resumes any suspended resources (lines 1055–1070).
rollback: a new transaction, or a participant?
rollback checks for completion and goes straight to processRollback(status, false) (lines 858–866). Here is the fork inside.
if (status.hasSavepoint()) {
// ...
status.rollbackToHeldSavepoint();
}
else if (status.isNewTransaction()) {
// ...
doRollback(status);
}
else {
// Participating in larger transaction
if (status.hasTransaction()) {
if (status.isLocalRollbackOnly() || isGlobalRollbackOnParticipationFailure()) {
// ...
doSetRollbackOnly(status);
}
// ...
}
// ...
}
// ...
// Raise UnexpectedRollbackException if we had a global rollback-only marker
if (unexpectedRollback) {
throw new UnexpectedRollbackException(
"Transaction rolled back because it has been marked as rollback-only");
}
- A transaction I opened myself (
isNewTransaction) getsdoRollback, which iscon.rollback()(lines 351–363). - A participant has no right to roll the connection back. Instead, governed by
globalRollbackOnParticipationFailure(defaulttrue, line 138),doSetRollbackOnlyleaves a rollback marker on the transaction object. For JDBC that isConnectionHolder.setRollbackOnly()(lines 366–373,ResourceHolderSupportlines 67–69).
Now the puzzle resolves. That ConnectionHolder is the one the outer and inner methods share. After the inner method leaves its marker and returns, the outer method's commit hits the second check, isGlobalRollbackOnly(), which is now true, and goes to processRollback(status, true). The outer transaction is a new one, so con.rollback() runs, and because unexpected was true, an UnexpectedRollbackException is thrown at the end.
The yellow box is the marker left on the holder both methods share; the red box is the exception the outer method gets because of it.
Example 3: the outer method swallows the inner exception
An outer @Transactional method inserts one row and calls an inner method on another bean. The inner method inserts one row and throws IllegalStateException; the outer method catches it and returns normally. It is run twice, with the inner propagation set to REQUIRES_NEW and then REQUIRED.
// An example written for this post (not Spring's code)
@Transactional
public void outerWithRequired() {
printConnection(dataSource, "outer REQUIRED");
jdbc.update("INSERT INTO member (name) VALUES (?)", "outer");
try {
inner.insertThenFailRequired(); // the inner method is @Transactional(propagation = REQUIRED)
}
catch (IllegalStateException ex) {
System.out.println(" [outer] caught the inner exception: " + ex.getMessage());
}
}
printConnection prints the connection obtained through DataSourceUtils.getConnection and getCurrentTransactionName().
== 1. REQUIRES_NEW fails inside REQUIRED
[outer REQUIRED] transaction name = ex.u15.PropagationExample$OuterService.outerWithRequiresNew
[outer REQUIRED] connection = conn3: url=jdbc:h2:mem:u15 user=SA
[inner REQUIRES_NEW] transaction name = ex.u15.PropagationExample$InnerService.insertThenFailRequiresNew
[inner REQUIRES_NEW] connection = conn4: url=jdbc:h2:mem:u15 user=SA
[outer] caught the inner exception: failed inside
the outer method returned normally
rows left = [outer]
== 2. REQUIRED fails inside REQUIRED
[outer REQUIRED] transaction name = ex.u15.PropagationExample$OuterService.outerWithRequired
[outer REQUIRED] connection = conn7: url=jdbc:h2:mem:u15 user=SA
[inner REQUIRED] transaction name = ex.u15.PropagationExample$OuterService.outerWithRequired
[inner REQUIRED] connection = conn7: url=jdbc:h2:mem:u15 user=SA
[outer] caught the inner exception: failed inside
the outer method threw: UnexpectedRollbackException - Transaction rolled back because it has been marked as rollback-only
rows left = []
Run 1 is the REQUIRES_NEW path from section 4. The inner method got a different connection (conn4) and a different transaction name, only the inner part was rolled back, and the outer row survived. Run 2 is the participation path. The inner method's transaction name is the outer method's name and the connection is the same conn7. Although the outer method caught the exception and returned normally, it got an UnexpectedRollbackException instead of a commit, and both rows are gone. The exception message is exactly the string from processRollback above.
Easy to get wrong
- Catching the inner exception does not save the outer transaction. That is run 2. The marker is left by the interceptor the moment the inner method exits, so whatever the outer method does afterwards, a commit is impossible. The Javadoc of
setGlobalRollbackOnParticipationFailurerecommendsPROPAGATION_NESTED, which uses a savepoint, as the way to handle a failing sub-transaction, and notes that it works withDataSourceTransactionManagerbut not withJtaTransactionManager(lines 278–283). UnexpectedRollbackExceptionis thrown only at the outermost level. While participating, unlessfailEarlyOnGlobalRollbackOnlyis set (defaultfalse, line 140),unexpectedRollbackis reset tofalse(lines 916–919). With three levels, the middle one passes quietly and the outermostcommitis where it blows up.- Never call
rollbackaftercommitthrew. Even a failedcommitmarks the transaction completed and finishes the cleanup, so arollbackafter it becomes anIllegalTransactionStateException. The Javadoc ofPlatformTransactionManagerwarns about this in bold (lines 106–109). It is also why the interceptor in section 3 calls exactly one ofrollbackorcommit.
Going deeper
A lot was left out to keep the main thread clear. It is in the study notes, but here only the names. TransactionTemplate, which wraps a lambda in a transaction without any annotation (line 65). The second branch of invokeWithinTransaction for CallbackPreferringPlatformTransactionManager, a manager that takes a callback instead of getTransaction, commit and rollback (line 44, from TransactionAspectSupport line 412). mode = ASPECTJ, which weaves with AspectJ instead of proxies, and the configuration class name it reads (TransactionManagementConfigurationSelector line 51, TransactionManagementConfigUtils line 49). @TransactionalEventListener, which calls an event listener only after commit (line 70; the default phase AFTER_COMMIT is at line 78; where the events of Part 12 meet the synchronization callbacks of this post). TransactionExecutionListener from 6.1, which listens before and after begin, commit and rollback (line 38). Creating, rolling back to and releasing the savepoint that NESTED uses (AbstractTransactionStatus lines 142–170). How the timeout becomes a deadline on the ConnectionHolder (AbstractPlatformTransactionManager lines 596–601, DataSourceTransactionManager lines 303–306). The rollback handling when a commit itself fails, doRollbackOnCommitException (lines 953–976). And ReactiveTransactionManager with the reactive branch of invokeWithinTransaction (lines 341–358) waits for Part 19.
Summary
| Concept | One line |
|---|---|
PlatformTransactionManager |
three methods, getTransaction, commit, rollback; the per-technology implementations extend AbstractPlatformTransactionManager |
TransactionDefinition |
how to open: seven propagation options, isolation, timeout, read-only; defaults REQUIRED, the database's own, -1, false |
TransactionStatus |
what state it is in; isNewTransaction() decides the fate of commit and rollback |
@EnableTransactionManagement |
registers the auto-proxy creator plus an advisor (pointcut, interceptor, attribute source) |
TransactionInterceptor |
getTransaction before the call, commit or rollback after it; this advice both opens and closes the transaction |
rollbackOn |
by default only RuntimeException and Error roll back; with rollbackFor/noRollbackFor the rule closest in the exception hierarchy wins |
| self-invocation | this.inner() skips the proxy, so @Transactional is ignored; the same goes for REQUIRES_NEW |
getTransaction |
doGetTransaction → isExistingTransaction → by propagation: startTransaction (doBegin), suspend, savepoint or participate |
TransactionSynchronizationManager |
binds DataSource → ConnectionHolder in a ThreadLocal map so every JDBC call on the same thread uses the same connection |
DataSourceUtils.getConnection |
returns the bound connection if there is one, and releaseConnection does not close it; the route JdbcTemplate takes |
| rollback marker | a failing participant leaves rollbackOnly on the shared holder; the outer commit sees it, rolls back and throws UnexpectedRollbackException |
Up next
The first unit of Phase 4 (transactions and data access) in the map of Part 1 is done. Section 5 of this post only said that "JdbcTemplate takes the thread-bound connection out through DataSourceUtils" and moved on. The next post, Part 18: JdbcTemplate — the Template-Callback Pattern and Exception Translation, follows what JdbcTemplate does once it has the connection: creating the Statement, mapping results with a RowMapper, and translating SQLException into Spring's DataAccessException hierarchy. The ConnectionHolder of this post shows up again there.
What the final commit from the transaction manager turns into inside the database server is in Digging into MariaDB's Source 12, and Kafka's transactions, which share only the name, are in Digging into Kafka's Source 12.
- Part 1: How I Read It with AI, and the Map
- Part 4: How a Bean Gets Created — the Creation Flow and the Three-Level Cache
- Part 9: @Conditional, @Profile and @Import — Choosing Beans by Condition
- Part 10: How an AOP Proxy Gets Built — the JDK Way and the CGLIB Way
- Part 11: How @Aspect Gets Discovered and How the Advice Chain Runs
- Part 13: ResolvableType and MergedAnnotations — How Spring Reads Types and Annotations