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, while noRollbackFor 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.
REQUIRED and REQUIRES_NEW transaction propagation methods

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.

Spring AOP advice wrapping and ordering

Here is the sequence of events that leads to the rollback:

  1. 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.
  2. Transaction Aspect Runs Second: It sets up the transaction for methodB (which joins the transaction from methodA as the default propagation mode is REQUIRED).
  3. Method Execution and First Exception: The call proceeds to the actual business logic in methodB. When an OptimisticLockingException occurs, it propagates up the call stack.
  4. 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.
  5. 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.
  6. Retry Attempt: The @Retryable aspect initiates a retry. It doesn't know that the transaction is already in a failed state.
  7. 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 to MyRetryableComponent is a cross-bean call. This ensures the @Retryable proxy is triggered correctly.
  • Atomicity is Maintained: methodB joins the transaction started by methodA. The retries in MyRetryableComponent happen within this single transaction, and it is only marked for rollback if all retries fail, preserving atomicity across both methods.