The Proxy Paradox: Why Spring @Transactional Vanishes

We’ve all been there. You annotate @Transactional on a critical method in your Spring application, run mvn test, watch the green checkmarks fly by, and feel good about yourself. Everything’s going swell innit?

But then you open the transaction log and find… nothing. Where did your transaction go?

No connection enlisted. No timeout. No rollback on error. The code did execute, but a transaction was not created.

Congratulations, you’ve just met the Proxy Paradox. The coding equivalent to plugging in your phone overnight and waking up to 7% battery.

Stick around for a few minutes, and you’ll know why this happens, and how to mitigate this behavior with some well-known patterns.

Understanding Spring AOP

The seeds of this bug are planted in Spring’s Aspect Oriented Programming (AOP).

In Spring, AOP is used to decouple cross-cutting concerns (like logging, security, or transactions) from your core business logic to keep code modular. It achieves this by wrapping your beans in dynamic proxies that intercept method calls to inject this extra behavior at runtime — without modifying the original code.

Spring AOP is how @Transactional annotation works in the first place.

When a Spring container starts up, it scans your beans. It asks, “Hey, does this class have any aspect-related annotations, like @Transactional, @Async, or @Cacheable?”

If the answer is yes, it doesn’t give you the raw bean. It wraps that bean in a proxy (either a JDK dynamic proxy or a CGLIB-generated subclass). This wrapper intercepts calls from the outside world and funnels them through an interceptor chain.

However, the interceptor chain does not come into picture if a call comes internally, i.e., from within the class.

The Bug in action

Take a look at the following code snippet:

@Service
class WalletService {

    // The entry point
    public void pay(BigDecimal amount) {
        // The internal call causing the issue
        withdrawMoney(amount); 
    }

    @Transactional
    public void withdrawMoney(BigDecimal amount) {
        // ... complex logic with database writes ...
    }
}

Here, withdrawMoney() is marked @Transactional. Any calls to this method from outside of WalletService (say, a controller) work as expected. The call goes through the proxy, a transaction is started, and then the raw bean’s method is executed.

However, if the call to withdrawMoney() comes through pay(), it executes non-transactionally.

Why? Because the call to withdrawMoney() happens inside the raw bean, bypassing the proxy completely. Spring’s TransactionInterceptor never comes into picture. No connection is bound to the thread. No commit. No rollback.

But… sometimes it works!

If you have a colleague who claims that this works, they’re probably using AspectJ Load-Time Weaving:

@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)

AspectJ is different. It doesn’t use proxies; it modifies the actual bytecode of your class during class loading. It literally weaves the transaction logic into your original method.

  • Pros: Self-invocation works perfectly.
  • Cons: Requires a special Java agent, adds complexity to the build process, increases startup time, and is generally overkill for standard web apps.

Practical Fixes

So, you’re stuck with the proxy issue. How do you fix it? Here are the top 5 solutions, ranked from “Best Practice” to “Please Don’t Do This”:

1. Refactor (Recommended)

Move withdrawMoney() to its own @Service:

@Service
class PaymentService {
    private final WalletService walletService; // Inject dependency
    
    public void pay(BigDecimal amount) {
        walletService.withdrawMoney(amount); // External call!
    }
}

This is the cleanest solution. It fits SOLID principles, and makes unit testing much easier.

2. Self-Injection

You can actually ask Spring to inject the proxy into the bean itself:

@Service
class WalletService {
    @Lazy 
    @Autowired 
    private WalletService self;

    public void pay(BigDecimal amount) {
        self.withdrawMoney(amount); // Goes through the proxy
    }
}

This works, but feels weird. It also makes use of Field Injection, which is not considered a best practice.

This approach will not work at all if you use Constructor Injection (you will be hit with circular reference errors).

3. Programmatic Transactions

Why not introduce an explicit transaction?

transactionTemplate.execute(status -> {
    withdrawMoney(amount);
    return null;
});

This is the easiest and simplest fix. It does add some boilerplate code, but clarity beats magic any day.

4. AopContext.currentProxy()

You can force the method call through the AOP Proxy:

((WalletService) AopContext.currentProxy()).withdrawMoney(amount);

This will route the internal method call through the proxy. This works, but it makes the AOP abstraction leaky. Your business logic is forced to learn framework details. Purists will frown at this, but it will also come in your way if you want to migrate to AspectJ later. Use sparingly.

5. AspectJ Load-Time Weaving

We’ve seen this earlier:

@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)

This is great if you want self-invocation across thousands of beans. But it introduces a lot of complexity, and is rarely worth it for a handful of cases.

Summary

The Proxy Paradox is a rite of passage for Spring developers. Just remember the flow:

  1. External Call → Proxy → Aspects run → Logic runs.
  2. Internal Call → Raw this object → Logic runs (No Aspects).

Re-organize your methods, self-inject if you must, or drop down to TransactionTemplate—but never trust @Transactional on a self-invoked method again.


Sound Off: What quirky Spring “gotcha” cost you the most debug minutes? Drop a comment below with the annotation that betrayed you!

Leave a Reply

Your email address will not be published. Required fields are marked *