Why Your @Retryable Fails with @Transactional in Spring (and How to Fix It)
Last week I bumped into an interesting bit of code where Spring's @Transactional
and @Retryable
annotations had a hidden conflict. It might not be obvious how these powerful annotations affect each other, so I decided to write a short article about what happens when Spring's AOP magic gets a little messy.
How Spring's Transactional Magic Works ✨
Spring transactions provide a declarative way to manage database operations, ensuring data integrity. They function by leveraging Aspect-Oriented Programming (AOP) to intercept method calls. When a method with a @Transactional
annotation is called, Spring's AOP proxy intercepts the call and starts a transaction before the method executes.
- Rollback Behavior: By default, a Spring transaction will only roll back when an unchecked exception is thrown. This is because unchecked exceptions typically represent a fatal, unrecoverable problem that leaves the application in an unstable state. Checked exceptions, on the other hand, are often considered recoverable, and the default behavior is to commit the transaction when one is thrown. This can be customized with the
@Transactional
annotation's parameters. For example,@Transactional(rollbackFor = MyCheckedException.class)
will roll back for a checked exception, whilenoRollbackFor
will prevent a rollback. - Propagation Modes: An important aspect is propagation modes, which define how a transaction relates to existing ones. These are specified within the
@Transactional
annotation (e.g.,@Transactional(propagation = Propagation.REQUIRED)
).REQUIRED
(default): The method joins an active transaction. If one isn't present, a new one is created.REQUIRES_NEW
: A new, independent transaction is always started. The existing transaction is suspended.SUPPORTS
: The method uses an existing transaction if it finds one but runs without a transaction otherwise.MANDATORY
: The method must be called within an active transaction or it throws an exception.NEVER
: The method must not be called within a transaction.
How AOP and @Transactional Work Together
The magic behind Spring's declarative transaction management is AOP. When you annotate a method or class with @Transactional
, Spring creates a proxy for that bean. Instead of directly calling the original method, the framework first calls the proxy. This proxy contains an "advice" — a piece of code that handles the transaction logic. Before calling the actual business method, the advice begins the transaction. After the method returns, the advice determines whether to commit the transaction (if there were no exceptions) or roll it back (if an unchecked exception occurred). This entire process happens seamlessly behind the scenes, thanks to the AOP framework. This is the reason why @Transactional and similar AOP based annotations won't work on private methods. Calling a private method stays "within" the class and won't go through the proxy, so the advice can't intercept it.
The Situation: When Good Annotations Go Bad 💥
I was working on a codebase with a classic nested setup: a @Transactional
methodA
called another @Transactional
methodB
. methodB
did database updates and co uld fail with an OptimisticLockingException
. To handle this, it was also annotated with @Retryable
. Spring Retry is a relatively rarely used but very clever tool that can manage automatic retries of method calls if a method fails with a specific exception. The @Retryable
annotation also uses Spring AOP to work.
@Component
public class MyService {
@Autowired
private MyRetryableComponent retryableComponent;
@Transactional
public void methodA() {
retryableComponent.methodB();
}
}
@Component
public class MyRetryableComponent {
@Retryable(value = {OptimisticLockingException.class}, maxAttempts = 3)
@Transactional // <--- This is the problem!
public void methodB() {
// ... some work on the database
}
}
The intended goal of this setup was that if methodB
fails, it should be retried x times and if it succeeds the whole transaction can continue and commit at the end. This piece of code was running in production for years without issues, but the changes I made actually started to cause an optimistic locking exception in methodB
and I noticed that while the method was retried successfully and both methods completed normally, the database did not have the data in it that should have been added during the execution of methodA
and methodB
.
logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.jdbc.datasource=DEBUG
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
Turning on transaction logging
The Root Cause: Advice Precedence 🕵️
When a method is annotated with both @Transactional
and @Retryable
, Spring's AOP framework determines the order in which the aspects are applied. These advices wrap the actual method like layers of an onion. When entering the method execution goes from highest precedence to lowest, when exiting it is the other way around. By default, the transactional advice has the lowest precedence and retryable has one higher so it will run first.
Here is the sequence of events that leads to the rollback:
- Retryable Aspect Runs First: The
@Retryable
is executed first, it wraps the Transactional advice. It sets up exception handling an a retry loop then the execution passes to Transactional. - Transaction Aspect Runs Second: It sets up the transaction for
methodB
(which joins the transaction frommethodA
as the default propagation mode isREQUIRED
). - Method Execution and First Exception: The call proceeds to the actual business logic in
methodB
. When anOptimisticLockingException
occurs, it propagates up the call stack. - Transactional Aspect Intercepts the Exception: The exception is first caught by the inner layer of advice: the transactional aspect. Upon seeing an unchecked exception like
OptimisticLockingException
, the transactional aspect immediately marks the entire, active transaction as rollback-only. - Retryable Aspect Intercepts the Exception: After the transactional aspect has already acted, the exception continues to propagate up. The
@Retryable
aspect, which is the outer layer, now catches the exception. - Retry Attempt: The
@Retryable
aspect initiates a retry. It doesn't know that the transaction is already in a failed state. - Successful Retry (but futile): The subsequent retry might succeed, but the transaction's rollback-only flag remains set. When the original
methodA
completes and attempts to commit the transaction, the transaction manager checks this flag and rolls back the entire transaction.
The Solution: A Refined Approach 💡
The initial thought of using Propagation.REQUIRES_NEW
on methodB
is a valid option for handling retries, as it isolates the failing logic by creating a new separate transaction for methodB
. However, this breaks the atomicity between methodA
and methodB
, meaning methodA
's later failure won't roll back methodB
's committed changes. In some cases, this is acceptable, but for a true solution that maintains atomicity and data integrity, a more robust refactor is needed.
Option 1: Manual Retries
One straightforward solution is to simply not use the @Retryable
annotation. Instead, you can wrap the call to methodB
in a try-catch
block with a manual retry loop within methodA
. This approach gives you full control and avoids any AOP conflicts. The drawback is that it introduces more boilerplate code and mixes business logic with infrastructure concerns, reducing code readability and maintainability.
Option 2: The Layered Design Pattern
The most robust and idiomatic Spring solution is to separate the core service logic from the retryable logic. This ensures that methodB
is always transactional for any caller, while still allowing for the declarative @Retryable
annotation to function correctly. This is achieved by introducing a third component.
@Component
public class MyService {
@Autowired
private MyCoreService coreService;
@Transactional
public void methodA() {
coreService.methodB();
}
}
@Component
public class MyCoreService {
@Autowired
private MyRetryableComponent retryableComponent;
@Transactional
public void methodB() {
retryableComponent.doTransactionalWork();
}
}
@Component
public class MyRetryableComponent {
@Retryable(
value = {OptimisticLockingException.class},
maxAttempts = 3
)
public void doTransactionalWork() {
// ... core logic that may fail with optimistic locking
}
}
This layered design works because:
- Data Integrity is Guaranteed: Any caller of
MyCoreService.methodB()
gets a transaction due to the@Transactional
annotation. - AOP Proxies Work as Intended: The call from
MyCoreService
toMyRetryableComponent
is a cross-bean call. This ensures the@Retryable
proxy is triggered correctly. - Atomicity is Maintained:
methodB
joins the transaction started bymethodA
. The retries inMyRetryableComponent
happen within this single transaction, and it is only marked for rollback if all retries fail, preserving atomicity across both methods.