@Transactional Under the Hood: The Proxy, the Transaction Manager, and the Self-Invocation Problem
This is the chapter where the proxy trilogy pays off. You learned in CH8 that Spring wraps your beans in CGLIB or JDK dynamic proxies. You learned in CH9 that those proxies chain MethodInterceptor instances through an AdvisorChainFactory. Now we see the single most important interceptor in any Spring application: TransactionInterceptor.
TransactionInterceptor: The AOP Advice That Runs Your Transactions
@Transactional is not magic. It is an annotation that triggers the registration of a BeanFactoryTransactionAttributeSourceAdvisor. This advisor pairs two things: a pointcut that matches methods annotated with @Transactional, and an advice that is a TransactionInterceptor.
TransactionInterceptor extends TransactionAspectSupport and implements MethodInterceptor from the AOP Alliance API. When the proxy intercepts a method call, it delegates to TransactionInterceptor.invoke(), which calls invokeWithinTransaction(). The sequence is direct:
// Simplified from TransactionInterceptor.invoke()
public Object invoke(MethodInvocation invocation) throws Throwable {
Class<?> targetClass = AopUtils.getTargetClass(invocation.getThis());
return invokeWithinTransaction(
invocation.getMethod(),
targetClass,
invocation::proceed // the callback that calls the actual method
);
}
Inside invokeWithinTransaction(), four things happen in order:
- Read the transaction attributes from the method (
TransactionAttributeSource) - Determine the correct
PlatformTransactionManager - Begin, commit, or rollback the transaction
- Call the actual method via
invocation.proceed()
This is the same interceptor chain you saw in CH9. TransactionInterceptor sits in the advisor chain alongside any @Around aspects, security interceptors, or caching interceptors. The ordering depends on @Order or Ordered interface implementations. By default, TransactionInterceptor has the lowest precedence, meaning it runs outermost in the chain. Your @Around advice executes inside the transaction boundary unless you explicitly order it otherwise.
How TransactionAttributeSource Reads Your Annotation
When TransactionInterceptor needs to know the propagation level, isolation, timeout, readOnly flag, and rollback rules for a method, it asks TransactionAttributeSource. The default implementation is AnnotationTransactionAttributeSource, which delegates to a list of TransactionAnnotationParser instances.
SpringTransactionAnnotationParser reads @Transactional and builds a RuleBasedTransactionAttribute:
// What Spring actually parses from your annotation
@Transactional(
propagation = Propagation.REQUIRES_NEW,
isolation = Isolation.READ_COMMITTED,
timeout = 30,
readOnly = false,
rollbackFor = PaymentException.class,
noRollbackFor = RetryableException.class
)
public void processPayment(TenantId tenantId, PaymentRequest request) { ... }
Each attribute maps to a concrete behavior:
propagationcontrols whether a new transaction is created, an existing one is joined, or the call is suspendedisolationmaps directly toConnection.setTransactionIsolation(int)timeoutsetsTransactionDefinition.setTimeout(), enforced by the transaction managerreadOnlytriggers Hibernate flush modeMANUALand may hint the JDBC driver to route to a replicarollbackForandnoRollbackForbuild a list ofRollbackRuleAttributechecked on exception
The attribute source caches parsed attributes per method. The first invocation pays the reflection cost. Subsequent calls are a ConcurrentHashMap lookup.
PlatformTransactionManager Selection
Spring must decide which PlatformTransactionManager to use. In a typical SaaS backend, you might have multiple: one JpaTransactionManager for your primary database, one DataSourceTransactionManager for a reporting database, one JmsTransactionManager for message queues.
The selection logic in TransactionAspectSupport.determineTransactionManager():
- If
@Transactional(transactionManager = "reportingTxManager")specifies a qualifier, Spring looks up that bean by name - If no qualifier, Spring checks for a cached default
- If no cache, it calls
BeanFactory.getBean(PlatformTransactionManager.class) - If multiple candidates exist and none is
@Primary, startup fails withNoUniqueBeanDefinitionException
For the multi-tenant SaaS backend:
@Configuration
public class TransactionManagerConfig {
@Bean
@Primary
public PlatformTransactionManager primaryTxManager(EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
@Bean("reportingTxManager")
public PlatformTransactionManager reportingTxManager(
@Qualifier("reportingDataSource") DataSource ds) {
return new DataSourceTransactionManager(ds);
}
}
@Service
public class TenantReportService {
@Transactional(transactionManager = "reportingTxManager", readOnly = true)
public TenantReport generateReport(TenantId tenantId) {
// Uses the reporting DataSource, not the primary JPA one
return reportRepository.buildReport(tenantId);
}
}
When you omit the qualifier, Spring uses the @Primary manager. This is why most applications work with a single @Transactional annotation without specifying the manager. The moment you add a second transaction manager without @Primary, every unqualified @Transactional breaks at startup.
The Transaction Lifecycle
Once the manager is selected, TransactionInterceptor delegates to AbstractPlatformTransactionManager, which orchestrates the actual JDBC transaction. The lifecycle:
1. getTransaction(TransactionDefinition)
└─ doGetTransaction() → get or create TransactionObject
└─ isExistingTransaction() → check ThreadLocal for active tx
└─ doBegin() → Connection.setAutoCommit(false)
2. Target method executes via invocation.proceed()
3a. On success: commit(TransactionStatus)
└─ triggerBeforeCommit() → TransactionSynchronization callbacks
└─ triggerBeforeCompletion()
└─ doCommit() → Connection.commit()
└─ triggerAfterCommit()
└─ triggerAfterCompletion()
3b. On exception: rollback(TransactionStatus)
└─ triggerBeforeCompletion()
└─ doRollback() → Connection.rollback()
└─ triggerAfterCompletion()
The rollback decision is not automatic for all exceptions. By default, Spring rolls back only on unchecked exceptions (RuntimeException and Error). Checked exceptions commit. This is the single most common source of transaction bugs. Your PaymentProcessingException extends Exception will commit the transaction unless you specify rollbackFor = PaymentProcessingException.class.
TransactionSynchronizationManager: The Thread-Local State
All transaction state lives in TransactionSynchronizationManager, a class composed entirely of ThreadLocal fields:
// Actual fields from TransactionSynchronizationManager
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");
The resources map binds the DataSource to its Connection (wrapped in a ConnectionHolder). When your JpaRepository calls EntityManager.find(), the persistence provider asks DataSourceUtils.getConnection(dataSource), which looks up this ThreadLocal map to get the transactional Connection. That is how every database call in the same thread participates in the same transaction without explicit Connection passing.
This is also why @Transactional breaks with @Async. The async method runs on a different thread. Different thread means different ThreadLocal. No transaction state is visible.
The Self-Invocation Problem
This is the bug that burns every Spring developer at least once.
Consider the multi-tenant SaaS order processing:
// BROKEN: self-invocation bypasses the proxy
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Transactional
public OrderResult processOrder(TenantId tenantId, OrderRequest request) {
Order order = Order.create(tenantId, request);
orderRepository.save(order);
// This call goes to 'this', not the proxy
this.validateOrder(order);
return OrderResult.success(order.getId());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void validateOrder(Order order) {
// Developer expects a new, independent transaction
// Actual behavior: runs inside the existing transaction
// If validation fails and throws, the entire order rolls back
validationRuleEngine.validate(order);
auditRepository.save(AuditEntry.validated(order));
}
}
Recall from CH8: the bean in the application context is a CGLIB proxy. When external code calls orderService.processOrder(), the call hits the proxy, which runs the interceptor chain, which starts a transaction. But inside processOrder(), the call this.validateOrder(order) goes directly to the target object. this is the real OrderService instance, not the proxy. The TransactionInterceptor never sees this call. REQUIRES_NEW is ignored. validateOrder() runs inside the same transaction as processOrder().
You can confirm this by enabling SQL logging:
logging.level.org.springframework.transaction=TRACE
Expected output for correct behavior:
Creating new transaction: OrderService.processOrder
Suspending current transaction, creating new transaction: OrderService.validateOrder
Committing transaction: OrderService.validateOrder
Resuming suspended transaction: OrderService.processOrder
Committing transaction: OrderService.processOrder
Actual output with self-invocation:
Creating new transaction: OrderService.processOrder
Committing transaction: OrderService.processOrder
One transaction. No suspension. The REQUIRES_NEW propagation never fires.
The Correct Pattern
The cleanest fix is structural: extract the called method into a separate bean.
// CORRECT: separate beans, separate proxies
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OrderValidationService validationService;
public OrderService(OrderRepository orderRepository,
OrderValidationService validationService) {
this.orderRepository = orderRepository;
this.validationService = validationService;
}
@Transactional
public OrderResult processOrder(TenantId tenantId, OrderRequest request) {
Order order = Order.create(tenantId, request);
orderRepository.save(order);
// This call goes through the proxy for OrderValidationService
validationService.validateOrder(order);
return OrderResult.success(order.getId());
}
}
@Service
public class OrderValidationService {
private final ValidationRuleEngine validationRuleEngine;
private final AuditRepository auditRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void validateOrder(Order order) {
validationRuleEngine.validate(order);
auditRepository.save(AuditEntry.validated(order));
}
}
Now validationService.validateOrder() goes through OrderValidationService’s proxy. The TransactionInterceptor sees REQUIRES_NEW, suspends the outer transaction, opens a new Connection, and runs validation independently.
An alternative when extraction is not practical: inject the proxy into the bean via ObjectProvider:
@Service
public class OrderService {
private final ObjectProvider<OrderService> selfProvider;
public OrderService(ObjectProvider<OrderService> selfProvider) {
this.selfProvider = selfProvider;
}
@Transactional
public OrderResult processOrder(TenantId tenantId, OrderRequest request) {
// selfProvider.getObject() returns the proxy, not 'this'
selfProvider.getObject().validateOrder(order);
return OrderResult.success(order.getId());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void validateOrder(Order order) { ... }
}
This works because ObjectProvider.getObject() fetches the bean from the container, which is the proxy. The call routes through the interceptor chain. But it is semantically odd: a bean holding a reference to its own proxy. Prefer the extraction pattern.
Propagation Mechanics at the Connection Level
The propagation levels are not abstract concepts. They map to concrete Connection operations:
-
REQUIRED (default): If a transaction exists, join it by reusing the same
Connection. If none exists, callDataSource.getConnection()andConnection.setAutoCommit(false). -
REQUIRES_NEW: Always get a new
Connectionfrom the pool. The existing transaction’sConnectionHolderis unbound fromTransactionSynchronizationManager, suspended, and re-bound after the new transaction completes. This means two connections are held simultaneously. In a pool-constrained environment, this can deadlock. -
NESTED: Create a JDBC
Savepointon the existingConnectionviaConnection.setSavepoint(). A rollback rolls back to the savepoint, not the entire transaction. The outer transaction can still commit. This requires the JDBC driver to support savepoints (most do).
The SaaS backend consequence: if your connection pool size is 10 and you have 10 threads each holding a REQUIRED connection, and each calls a REQUIRES_NEW method, all 10 threads need a second connection. The pool is exhausted. All 10 threads block waiting for a connection. Deadlock.
Summary
@Transactional is a proxy-based AOP interceptor. The proxy calls TransactionInterceptor, which reads the attributes, selects a transaction manager, manages the lifecycle through AbstractPlatformTransactionManager, and stores all state in thread-local variables via TransactionSynchronizationManager. Self-invocation bypasses the proxy and silently ignores your transaction configuration. The fix is structural: separate beans or injected proxies. Propagation levels map directly to JDBC Connection operations, with real resource implications for connection pool sizing.
The next two sections dive deeper into the self-invocation problem and propagation mechanics with full debugging demonstrations.