Spring @Transactional Deep Dive: Proxies, Propagation, and the Traps That Still Catch Senior Engineers
How Spring @Transactional actually works under the hood — proxies, thread-locals, propagation mechanics, rollback rules, and the silent failures that trip up experienced engineers.
You've used @Transactional hundreds of times. You know it starts a transaction, commits on success, and rolls back on a RuntimeException. But that mental model is a skeleton — it leaves out the machinery that causes real bugs in production.
This post is about that machinery. We'll go from annotation to Spring source code and cover the behaviours that trip up senior engineers. If you've hit the @Retryable + @Transactional combination specifically, there's a dedicated deep dive on why @Retryable fails with @Transactional and how to fix it — that post covers the AOP advice ordering problem in detail. In this one I go through other important aspects and good to knows. Buckle up, this is a long one.
Spring Transactions and Database Transactions: What's the Relationship?
When most people think "transaction" in a Spring app, they think database — and that's usually right, but it's not the whole picture. Spring's transaction abstraction is intentionally decoupled from any specific resource. A PlatformTransactionManager could manage a database, a JMS message broker, or a custom resource entirely. In practice though, the vast majority of Spring applications use @Transactional to demarcate database transactions, and the two are tightly linked: when Spring commits a transaction, it ultimately calls connection.commit() on a JDBC connection.
The important flip side: a database transaction can exist without Spring's involvement — you can get a raw Connection from a DataSource and manage setAutoCommit, commit, and rollback yourself. Spring's abstraction is purely coordination logic sitting on top. What it gives you is the ability to span multiple method calls across multiple classes with the same database transaction, without passing a Connection object around manually. That coordination is handled through TransactionSynchronizationManager's thread-locals, which we'll look at in the next sections.
How Spring @Transactional Actually Works: The Proxy
The first thing to understand is that @Transactional annotation in Spring is not magic. It's AOP (Aspect-Oriented Programming), and AOP in Spring means proxies.
When Spring sees a bean with @Transactional methods, it doesn't give you back an instance of your class. It gives you back a proxy wrapping your class. Depending on the configuration, this is either:
- A JDK dynamic proxy — if your bean implements an interface
- A CGLIB proxy — if it doesn't (which is now the default in Spring Boot)
The proxy sits between the caller and your actual method. Every call goes through it.
Caller → [CGLIB Proxy] → TransactionInterceptor → Your Method
This has a direct and important consequence: if you call a @Transactional method from within the same class (this.method()), the call bypasses the proxy entirely and no transaction is started. We'll get back to this.
TransactionInterceptor: The Real Entry Point
TransactionInterceptor implements MethodInterceptor and is the component that wraps every @Transactional method call. Its invoke() method is barely any code at all — it immediately delegates to the real workhorse:
TransactionAspectSupport.invokeWithinTransaction()
This is worth reading directly. Simplified, it does this:
// 1. Resolve the transaction manager and transaction attributes
TransactionAttributeSource tas = getTransactionAttributeSource();
TransactionAttribute txAttr = tas.getTransactionAttribute(method, targetClass);
TransactionManager tm = determineTransactionManager(txAttr);
// 2. Create or join a transaction
TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);
Object retVal;
try {
// 3. Invoke the actual method
retVal = invocation.proceed();
} catch (Throwable ex) {
// 4. Handle rollback or commit on exception
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
} finally {
// 5. Clean up the TransactionInfo from thread-local
cleanupTransactionInfo(txInfo);
}
// 6. Commit
commitTransactionAfterReturning(txInfo);
return retVal;
That's it. Clean, linear, easy to reason about — but each step has depth.
TransactionSynchronizationManager: The Thread-Local Store
This is the class most developers have never heard of, yet it underpins everything about how Spring transactions work across method calls.
TransactionSynchronizationManager holds a set of ThreadLocal maps that store the entire state of the current transaction for the executing thread:
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<Boolean> actualTransactionActive =
new NamedThreadLocal<>("Actual transaction active");
The resources map is particularly important — it's where the current JDBC Connection (or JPA EntityManager) is stored, keyed by the DataSource. This is how every repository and DAO in the call stack gets the same connection without you passing it around manually.
When REQUIRES_NEW creates a new transaction, Spring suspends the current transaction by saving all these thread-locals and replacing them with a fresh set. When the inner transaction completes, it restores the saved values.
The @Async Gotcha
@Async is a Spring annotation that tells the framework to execute a method in a separate thread, taken from a configured thread pool (by default SimpleAsyncTaskExecutor, though ThreadPoolTaskExecutor is standard in production). The caller doesn't wait — the method returns immediately and the actual work happens in the background. It's commonly used for things like sending emails, pushing notifications, or any side-effect that doesn't need to block the main request. To enable it, you need @EnableAsync on a configuration class.
The key thing to understand for what follows is that @Async methods run on a different thread than the caller — and that's exactly where the conflict with @Transactional comes from.
@Transactional
public void processOrder(Order order) {
orderRepo.save(order);
notificationService.sendConfirmation(order); // @Async method
}
// In NotificationService:
@Async
@Transactional
public void sendConfirmation(Order order) {
// This ALWAYS runs in a NEW transaction, even with REQUIRED propagation.
// The parent transaction is on a different thread — it's invisible here.
}
The @Async method runs on a different thread from a thread pool. That thread has empty thread-locals. There is no parent transaction to join. The REQUIRED propagation check looks at the thread-local, finds nothing, and creates a new transaction. Always.
If you need transactional behaviour in an async task, you need to design around this — for example, by committing the parent transaction first and passing only IDs, not entities.
Spring @Transactional Propagation: What Actually Happens Under the Hood
Most documentation lists propagation modes with brief descriptions. Here's what they actually do at the code level.
REQUIRED (default)
Calls TransactionSynchronizationManager.isActualTransactionActive(). If true, it joins the existing transaction by reusing the same connection from the resources map. If false, it calls doBegin() on the PlatformTransactionManager, which fetches a new connection from the pool and calls connection.setAutoCommit(false).
Joining an existing transaction does not create a savepoint. There is one physical transaction. If the inner method marks the transaction as rollback-only, the outer method will encounter an UnexpectedRollbackException at commit time — even if it caught the exception from the inner call.
REQUIRES_NEW
This one is more expensive than people realise. Spring calls suspend() on the current transaction, which:
- Unbinds all resources from
TransactionSynchronizationManager - Saves the current
TransactionInfoto the thread-local stack - Acquires a second connection from the pool
Your application now holds two open connections simultaneously. For the duration of the inner method, you have:
- Connection A: suspended, holding open resources, counting against your pool size
- Connection B: actively used by the inner transaction
This is a pool exhaustion risk that's easy to miss. If you have pool size 10 and a hot code path that nests REQUIRES_NEW, you can deadlock your own connection pool.
NESTED
NESTED is the often-confused middle ground. It does not create a new physical transaction. Instead, it creates a database savepoint on the existing connection:
// In DataSourceTransactionManager.doBegin() for NESTED:
if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NESTED) {
// ...
Object savepoint = txObject.createSavepoint();
// stored on TransactionStatus
}
If the nested method fails, Spring rolls back to the savepoint — not to the start of the transaction. The outer transaction is unaffected and can still commit. This is the semantically correct tool when you want partial rollback without isolation.
Not all databases support savepoints (though most modern ones do). If you use NESTED with a PlatformTransactionManager that doesn't support savepoints, you'll get a NestedTransactionNotSupportedException at runtime.
@Transactional Rollback Behaviour and the "Transaction Silently Rolled Back" Error
The rollback rules are applied inside completeTransactionAfterThrowing(). The logic checks the exception against the rollbackFor and noRollbackFor rules defined on the annotation. The default rollback behaviour for @Transactional in Spring Boot is:
- Rollback:
RuntimeExceptionandError - No rollback: checked exceptions (
Exceptionsubclasses that aren'tRuntimeException)
This surprises people. A SQLException wrapped in a checked exception will not trigger a rollback by default.
@Transactional
public void doSomething() throws IOException {
repo.save(entity);
throw new IOException("disk full"); // NO ROLLBACK by default
}
When Spring decides to rollback, it doesn't immediately call connection.rollback(). First it calls doSetRollbackOnly() on the TransactionStatus, which sets a boolean flag. This is why the behaviour differs between REQUIRED and REQUIRES_NEW:
- In REQUIRED: setting rollback-only marks the shared transaction. Any outer transaction attempting to commit will trigger
UnexpectedRollbackExceptionbecause the commit call goes toAbstractPlatformTransactionManager, which checks this flag first. - In REQUIRES_NEW: the inner transaction is rolled back immediately when it finishes. The outer transaction is unaffected because they're separate objects with separate flags.
UnexpectedRollbackException: Transaction silently rolled back
This is one of the most confusing errors in Spring and the source of many production bugs. The full exception is:
org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only
It means: "You tried to commit, but something earlier in this transaction already marked it for rollback." This happens in a specific pattern:
@Transactional
public void outer() {
try {
inner(); // throws RuntimeException, marks tx rollback-only
} catch (RuntimeException e) {
// You caught the exception — you think you handled it
log.warn("inner failed, continuing");
}
// outer tries to commit here → UnexpectedRollbackException
}
@Transactional // REQUIRED — joins the SAME transaction
public void inner() {
throw new RuntimeException("something went wrong");
}
You caught the exception. The code looks fine. But the transaction is already dead — Spring marked it rollback-only the moment inner() threw. The outer commit attempt fails. The fix is either REQUIRES_NEW on inner() (if you want true isolation) or accepting that the whole transaction must roll back. See the dedicated @Retryable + @Transactional post for a real-world case of this exact pattern causing silent data loss, that I wasted a lot of time on to figure out the first time I saw it happen.
Self-Invocation: Why "Methods Annotated with @Transactional Must Be Overridable"
Because the proxy sits between the caller and your class, any call within your class skips it. This is the root cause of the warning you'll sometimes see at startup:
Methods annotated with '@Transactional' must be overridable
Spring is telling you it cannot proxy that method — usually because it's final, or on a class that CGLIB can't subclass. The transaction annotation is silently ignored at runtime.
@Service
public class OrderService {
@Transactional
public void processAll(List<Order> orders) {
for (Order order : orders) {
processSingle(order); // ← calls this.processSingle(), bypasses proxy
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processSingle(Order order) {
// This annotation does NOTHING when called from processAll above.
// There is no new transaction. No proxy was involved.
}
}
There are three ways to fix this:
1. Separate beans (cleanest)
Move processSingle to a different Spring-managed bean. The call now goes through a proxy.
2. Self-injection
Inject the bean into itself and call through the proxy reference:
@Service
public class OrderService {
@Autowired
private OrderService self; // Spring handles circular dependency fine for this
@Transactional
public void processAll(List<Order> orders) {
for (Order order : orders) {
self.processSingle(order); // goes through proxy
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processSingle(Order order) { ... }
}
3. AspectJ load-time or compile-time weaving
If you enable AspectJ weaving instead of Spring's proxy-based AOP, the transaction logic is woven directly into the bytecode of your class. Self-invocation works because there's no proxy involved — the advice is part of the class itself. This is more powerful but adds build complexity.
Silent Failures: When @Transactional Does Nothing and Doesn't Tell You
Private methods
@Transactional on a private method is silently ignored. CGLIB can't override private methods, and Spring won't warn you.
@Transactional // ← does absolutely nothing
private void saveInternal() { ... }
Final methods with CGLIB
If you declare a class or method final, CGLIB cannot subclass/override it. Spring will either throw a BeanCreationExceptionat startup or — in some configurations — silently skip the proxy creation.
@Transactional on @Bean methods in non-@Configuration classes
This is subtle. If you define a @Bean on a @Component (not a @Configuration class), the method is not subject to CGLIB subclassing for inter-method calls. So @Transactional on such a @Bean method may not behave as expected. Full @Configuration classes have their @Bean methods intercepted.
Checked exceptions from JPA
Hibernate wraps many exceptions in PersistenceException (a RuntimeException), so they do trigger rollbacks. But if you write a service that catches the PersistenceException and rethrows a checked custom exception — no rollback.
@Transactional
public void create(Item item) throws BusinessException {
try {
repo.save(item); // might throw ConstraintViolationException (RuntimeException)
} catch (DataIntegrityViolationException ex) {
throw new BusinessException("duplicate item"); // checked — no rollback!
}
// Transaction commits here with a dirty connection state
}
The entity may or may not have been partially flushed. This is a real source of corruption bugs. In these cases you might want to use @Transactional(rollbackFor = BusinessException.class) to roll back properly.
readOnly: What It Actually Does
The readOnly = true flag is widely misunderstood. It does not prevent writes at the SQL level. Your code can still execute INSERT statements and they may succeed. What it actually does:
1. Sets a hint on the JDBC connection:
connection.setReadOnly(true);
This is a hint to the JDBC driver and connection pool. Some drivers pass this to the database, which can optimise the execution plan. Some ignore it. PostgreSQL, for example, doesn't use this hint to enforce read-only mode unless you've configured it to.
2. Transaction routing:
If you've configured a routing DataSource (e.g., primary/replica setup), TransactionSynchronizationManager.isCurrentTransactionReadOnly() returns true, which your router can use to direct connections to a read replica. Spring's LazyConnectionDataSourceProxy is commonly used for this.
So readOnly is less about enforcement and more about performance hints and routing. If you genuinely need to prevent writes, that's a database-level permission concern.
3. The "Missing" Optmization: Dirty checking:
This is arguably the most important effect of readOnly = true when using Hibernate/JPA.
When Hibernate loads an entity, it keeps a "snapshot" of that entity in the Persistence Context (First-level cache). At the end of the transaction, Hibernate performs Dirty Checking: it compares the current state of every loaded entity against its original snapshot to see if it needs to generate an UPDATE statement.
When readOnly = true is set:
- Spring sets the Hibernate
FlushModetoMANUAL. - Hibernate skips dirty checking entirely.
- It does not create snapshots of the entities.
The result: On a large result set (e.g., loading 1,000 users), you save a massive amount of CPU cycles and memory because Hibernate isn't tracking changes it knows it won't have to flush.
Transaction Synchronization Callbacks
This is a feature that most developers have never used but is extremely powerful.
TransactionSynchronizationManager allows you to register callbacks that fire at transaction lifecycle events:
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
// Called ONLY if the transaction actually committed.
// Perfect for: sending events, invalidating cache, triggering async jobs.
eventBus.publish(new OrderCreatedEvent(orderId));
}
@Override
public void afterCompletion(int status) {
// Called after commit OR rollback.
// status is STATUS_COMMITTED or STATUS_ROLLED_BACK or STATUS_UNKNOWN.
// Perfect for: releasing locks, cleanup regardless of outcome.
}
@Override
public void beforeCommit(boolean readOnly) {
// Called just before commit. Can still write to the DB here.
// Can throw to prevent commit.
}
});
The afterCommit() hook solves a real problem: if you publish an event or invalidate a cache during a transaction, and the transaction then rolls back, you've published an event for data that doesn't exist.
This is exactly what @TransactionalEventListener is built on. When you annotate an event listener with @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT), Spring registers a TransactionSynchronization under the hood and defers the event handling to the afterCommit phase.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
// This only fires if the surrounding transaction committed successfully.
cache.invalidate(event.getOrderId());
}
You can use registerSynchronization directly in any @Transactional method to get this behaviour for arbitrary code, without building a full event system.
The PlatformTransactionManager and What You're Actually Choosing
@Transactional doesn't know or care about databases directly. It delegates to a PlatformTransactionManager. Which one you're using determines what "transaction" even means:
| Manager | What it manages |
|---|---|
DataSourceTransactionManager |
Raw JDBC connections |
JpaTransactionManager |
JPA EntityManager + JDBC |
JtaTransactionManager |
XA transactions across multiple resources |
ReactiveTransactionManager |
Reactive streams (Project Reactor) |
The real difference isn’t just the resource — it’s what gets bound to the transaction.
JpaTransactionManager binds an EntityManager to the thread in addition to the JDBC connection. That makes a transaction a persistence context boundary, not just a commit/rollback boundary.
Within the transaction:
- The same
EntityManageris reused - First-level cache applies
- Lazy loading works
- Dirty checking happens
So choosing JpaTransactionManager means choosing persistence-context semantics.
Choosing DataSourceTransactionManager means choosing pure JDBC semantics.
TransactionTemplate: When Annotations Aren't Enough
Sometimes you need finer-grained control than an annotation can give you — for example, starting and committing a transaction in the middle of a method, or conditionally running something transactionally.
TransactionTemplate is the programmatic equivalent:
@Service
public class ReportService {
private final TransactionTemplate txTemplate;
public ReportService(PlatformTransactionManager txManager) {
this.txTemplate = new TransactionTemplate(txManager);
this.txTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
this.txTemplate.setTimeout(30); // seconds
}
public void generateAndArchive(Long reportId) {
// Some non-transactional prep work here...
Long savedId = txTemplate.execute(status -> {
Report report = reportRepo.findById(reportId).orElseThrow();
report.setStatus(Status.ARCHIVED);
return reportRepo.save(report).getId();
});
// More non-transactional work here...
}
}
The TransactionTemplate uses exactly the same TransactionAspectSupport infrastructure under the hood. It's not a different system — it's the same pipeline with explicit boundaries instead of annotation-detected ones.
One advantage: it makes the transaction boundary immediately visible in the code, which reduces the chance of the self-invocation mistake and makes it obvious what's and isn't covered by the transaction.
Source Code Worth Reading
If you want to go deeper, these are the classes in the Spring Framework source that implement everything described here:
TransactionInterceptor— the AOP interceptorTransactionAspectSupport— the core logic (invokeWithinTransactionis the key method)AbstractPlatformTransactionManager— propagation, rollback-only checks, commit/rollback flowTransactionSynchronizationManager— the thread-local storeDataSourceTransactionManager— concrete JDBC implementation, shows howdoBegin/doCommit/doRollbackworkJpaTransactionManager— the JPA version, shows EntityManager binding
Reading AbstractPlatformTransactionManager in particular is worth the hour. The commit logic, the propagation handling, and the suspend/resume mechanism are all in one place and well commented.
Common @Transactional Questions (Answered Directly)
These come up constantly in code review and interviews. Brief answers here — the full explanations are in the sections above.
Does @Transactional work on private methods? No. CGLIB cannot override private methods, so the proxy is never triggered. The annotation is silently ignored. No warning at runtime.
Does @Transactional work when you call a method from within the same class? No. this.method() bypasses the proxy entirely. Use a separate bean or self-injection through ApplicationContext.
What exceptions trigger a rollback by default? RuntimeException and Error. Checked exceptions (like IOException or custom checked exceptions) do not trigger rollback unless you add rollbackFor = MyException.class.
What does @Transactional(readOnly = true) actually do? It sets a hint on the JDBC connection and sets FlushMode.MANUAL on the Hibernate session. It does not enforce read-only at the SQL level. It's a performance optimisation, not a constraint.
Can you use @Transactional with @Async? You can annotate both, but the @Async method always starts a new transaction regardless of propagation setting. The @Async method runs on a different thread — the thread-local transaction context is not inherited.
What's the difference between REQUIRES_NEW and NESTED? REQUIRES_NEW opens a second physical connection and transaction. NESTED creates a savepoint on the existing connection. NESTED is cheaper but requires database savepoint support. REQUIRES_NEW gives true isolation; NESTED gives partial rollback within the same transaction.
Why am I getting UnexpectedRollbackException even though I caught the exception? Because catching the exception doesn't un-mark the transaction as rollback-only. Once an inner @Transactional method (with REQUIRED propagation) throws a RuntimeException, the transaction is marked for rollback. No amount of catching it in the outer method changes that. The outer commit will fail.
When NOT to use a transaction? In the following cases, a transaction can be counterproductive and hurt performance: slow operations, external calls, multi-threaded workloads, and purely read-only code. It’s usually a bad idea to perform a REST call inside a transaction, since the database connection and locks remain open while waiting for the external system. Likewise, a method that only reads from the database often doesn’t need a transaction — or should be marked @Transactional(readOnly = true) instead.
Summary
@Transactional in Spring is not a magic annotation. It's a proxy, a thread-local store, a stack of TransactionInfo objects, and a set of lifecycle callbacks — all wired together by TransactionAspectSupport.
Once you can picture the proxy interception, the thread-local state, and the PlatformTransactionManager delegation, these behaviours stop being surprising and start being predictable.