namalzi

← All posts

Digging into Spring's Source 17: @Transactional — Who Opens a Transaction and Who Closes It

Spring · Published

Put @Transactional on a method and somebody opens a transaction, rolls it back on an exception and commits it otherwise — this post follows who that somebody is, in the real source. The three interfaces PlatformTransactionManager, TransactionDefinition and TransactionStatus, the beans that @EnableTransactionManagement registers, the order in which TransactionInterceptor wraps a call, where propagation branches, why the connection is bound to a ThreadLocal, and traps such as "checked exceptions do not roll back" and "the outer method gets an UnexpectedRollbackException when the inner one fails", all checked with examples.

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.

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.

A figure in two parts. Order of use: pass a TransactionDefinition (how to open it — propagation, isolation, timeout, read-only, name) to getTransaction(definition), get back a TransactionStatus (what state it is in now — isNewTransaction, setRollbackOnly, isRollbackOnly, isCompleted), and hand that to commit(status) or rollback(status). Who implements it: under the method-less marker TransactionManager, the synchronous PlatformTransactionManager (this post) and the reactive ReactiveTransactionManager (Part 19) split off. AbstractPlatformTransactionManager (the template methods of Sections 4 and 6) implements PlatformTransactionManager, and DataSourceTransactionManager (JDBC, with JdbcTransactionManager below it), JtaTransactionManager (JTA) and JpaTransactionManager (JPA, Part 19) extend it 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()};
		};
	}

spring-tx/src/main/java/org/springframework/transaction/annotation/TransactionManagementConfigurationSelector.java lines 46–53

From @EnableTransactionManagement, TransactionManagementConfigurationSelector returns two names, AutoProxyRegistrar and ProxyTransactionManagementConfiguration. AutoProxyRegistrar registers the auto-proxy creator InfrastructureAdvisorAutoProxyCreator of Part 11, and ProxyTransactionManagementConfiguration together with its parent AbstractTransactionManagementConfiguration registers three beans, transactionAttributeSource, transactionInterceptor and transactionAdvisor, so that every bean carrying @Transactional gets a proxy 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.

	@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;
	}

spring-tx/src/main/java/org/springframework/transaction/annotation/ProxyTransactionManagementConfiguration.java lines 44–67

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;
	}

spring-tx/src/main/java/org/springframework/transaction/annotation/AbstractTransactionManagementConfiguration.java lines 82–92

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;
	}

spring-tx/src/main/java/org/springframework/transaction/annotation/SpringTransactionAnnotationParser.java lines 70–104

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;
	}

spring-tx/src/main/java/org/springframework/transaction/interceptor/AbstractFallbackTransactionAttributeSource.java lines 161–201

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

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();
			}
			// ...

spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionInterceptor.java lines 122–134

A call on the proxy enters TransactionInterceptor.invoke; invokeWithinTransaction reads the annotation and picks the transaction manager, createTransactionIfNecessary calls tm.getTransaction and binds a TransactionInfo to the ThreadLocal, then the original method runs. On a normal return cleanupTransactionInfo is followed by commitTransactionAfterReturning calling tm.commit; on an exception completeTransactionAfterThrowing asks rollbackOn and calls tm.rollback if true or tm.commit if false, then cleanupTransactionInfo and the exception is rethrown 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;
		}
		// ...

spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAspectSupport.java lines 333–410

  1. Read the annotation. This is getTransactionAttribute from section 2. Thanks to the cache, every call after the first is a map lookup.
  2. Pick the transaction manager. determineTransactionManager uses the annotation's value/transactionManager qualifier if there is one, otherwise a class-level @Qualifier, otherwise the configured transactionManagerBeanName, otherwise a manager set directly on the interceptor (the txManager in the section 2 code), and finally the single bean of type TransactionManager in the container, which it caches (lines 489–534; the lookup by type is line 527). That is why the example's H2Config declares exactly one PlatformTransactionManager bean.
  3. Open. createTransactionIfNecessary calls tm.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 why getCurrentTransactionName() prints ex.u15.…MemberService.report in the example output below.
  4. Run the original method. proceedWithInvocation() is the callback from invoke above, that is, invocation.proceed().
  5. Close. On a normal return commitTransactionAfterReturning calls tm.commit(status) (lines 682–689); on an exception completeTransactionAfterThrowing calls 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());
				}
				// ...
			}

spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAspectSupport.java lines 705–735

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);
	}

spring-tx/src/main/java/org/springframework/transaction/interceptor/DefaultTransactionAttribute.java lines 179–182

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);
	}

spring-tx/src/main/java/org/springframework/transaction/interceptor/RuleBasedTransactionAttribute.java lines 122–143

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.

When an exception leaves a @Transactional method, completeTransactionAfterThrowing (TransactionAspectSupport line 705) asks RuleBasedTransactionAttribute.rollbackOn(ex). For each rule it measures getDepth(ex), the number of steps up from the exception class to its parents; if any rule matches, the one with the fewest steps wins. If the winner is a rollbackFor rule the result is rollback(status); if it is a noRollbackFor rule, commit(status). If no rule matches, it falls back to super.rollbackOn: a RuntimeException or Error (DefaultTransactionAttribute line 181) rolls back, a checked exception commits. In Example 1, IllegalStateException and Exception with rollbackFor = Exception.class roll back and leave 0 rows; Exception with no rule commits and leaves 2 rows 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

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);
		}
	}

spring-tx/src/main/java/org/springframework/transaction/support/AbstractPlatformTransactionManager.java lines 372–421

getTransaction calls doGetTransaction to find the ConnectionHolder bound to the thread, then branches on isExistingTransaction. With no transaction, MANDATORY throws, REQUIRED, REQUIRES_NEW and NESTED go to startTransaction and doBegin for a new transaction, and SUPPORTS, NOT_SUPPORTED and NEVER get an empty transaction. With an existing one, handleExistingTransaction makes NEVER throw, NOT_SUPPORTED suspend and run without a transaction, REQUIRES_NEW suspend and start a new transaction on a new connection, NESTED create a savepoint, and REQUIRED, SUPPORTS and MANDATORY participate in the existing one Left: no transaction yet. Right: one already open. Only the green boxes have isNewTransaction set to true.

The flow has three parts.

  1. doGetTransaction(): the subclass builds an object describing "this thread's current transaction state". DataSourceTransactionManager looks up the ConnectionHolder bound to the thread through TransactionSynchronizationManager.getResource(dataSource) (section 5) and puts it in (lines 247–254).
  2. isExistingTransaction(): if that holder exists and isTransactionActive(), we are "already inside a transaction" (lines 257–260) and control goes to handleExistingTransaction.
  3. The propagation branch when there is none: MANDATORY throws, REQUIRED, REQUIRES_NEW and NESTED call startTransaction, 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

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());
			}
		}
		// ...
	}

spring-jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceTransactionManager.java lines 262–321

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.

DataSourceTransactionManager.doBegin calls dataSource.getConnection and setAutoCommit(false), then TransactionSynchronizationManager.bindResource puts a ConnectionHolder into the ThreadLocal resources map keyed by the DataSource. When jdbcTemplate.update inside the service calls DataSourceUtils.getConnection, getResource looks at the same map and, if a bound ConnectionHolder exists, returns the same connection and does not close it. After commit or rollback, doCleanupAfterCompletion does unbindResource, setAutoCommit(true) and releases the connection 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");

spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java lines 77–93

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

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);
	}

spring-tx/src/main/java/org/springframework/transaction/support/AbstractPlatformTransactionManager.java lines 733–758

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");
			}

spring-tx/src/main/java/org/springframework/transaction/support/AbstractPlatformTransactionManager.java lines 882–939

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.

Left: the inner method joined the same transaction with REQUIRED. When it throws, the interceptor calls tm.rollback, but isNewTransaction is false, so doSetRollbackOnly only leaves a rollbackOnly marker on the ConnectionHolder. When the outer method swallows the exception and returns normally and the interceptor calls tm.commit, isGlobalRollbackOnly is true, so processRollback runs con.rollback and throws UnexpectedRollbackException. Right: the inner method is REQUIRES_NEW; isNewTransaction is true, so only the inner connection is rolled back and the outer commit succeeds untouched 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

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.

More posts