The Proxy Paradox: Why Spring @Transactional Vanishes
Source: Dev.to
The Problem
You annotate @Transactional on a critical method, run your tests, and everything looks fine.
But when you check the transaction log you see no transaction: no connection enlisted, no timeout, no rollback on error. The method executed, but it ran outside a transaction.
This is the Proxy Paradox – Spring’s AOP proxy hides the transaction when a method calls another method in the same bean.
How Spring AOP Works
Spring uses Aspect‑Oriented Programming (AOP) to apply cross‑cutting concerns such as logging, security, or transactions.
When the container starts, it scans beans for aspect‑related annotations (@Transactional, @Async, @Cacheable, …).
If it finds any, Spring wraps the bean in a proxy (JDK dynamic proxy or CGLIB subclass).
The proxy intercepts external method calls, runs the interceptor chain (e.g., TransactionInterceptor), and then delegates to the original bean.
Self‑Invocation Issue
The interceptor chain is bypassed when a method calls another method inside the same bean.
@Service
class WalletService {
// Entry point
public void pay(BigDecimal amount) {
// Internal call – bypasses proxy
withdrawMoney(amount);
}
@Transactional
public void withdrawMoney(BigDecimal amount) {
// ... database writes ...
}
}
- Calling
withdrawMoneyfrom outsideWalletService→ goes through the proxy → transaction starts. - Calling
withdrawMoneyfrompay→ directthiscall → proxy is skipped → no transaction.
Why AspectJ Works Differently
If you enable AspectJ load‑time weaving:
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
AspectJ weaves transaction logic directly into the bytecode, so self‑invocation works.
- Pros: self‑invocation works out of the box.
- Cons: requires a Java agent, adds build complexity, increases startup time, and is usually overkill for typical web apps.
Solutions
1. Move the transactional method to another bean
@Service
class PaymentService {
private final WalletService walletService; // injected
public void pay(BigDecimal amount) {
walletService.withdrawMoney(amount); // external call
}
}
Clean, SOLID‑friendly, easy to test.
2. Self‑inject the proxy (field injection)
@Service
class WalletService {
@Lazy
@Autowired
private WalletService self; // proxy injected
public void pay(BigDecimal amount) {
self.withdrawMoney(amount); // goes through proxy
}
}
Works, but relies on field injection and can cause circular‑reference issues with constructor injection.
3. Use TransactionTemplate explicitly
@Autowired
private TransactionTemplate transactionTemplate;
public void pay(BigDecimal amount) {
transactionTemplate.execute(status -> {
withdrawMoney(amount);
return null;
});
}
Adds a bit of boilerplate but makes the transaction boundary explicit.
4. Call through the AOP proxy manually
import org.springframework.aop.framework.AopContext;
public void pay(BigDecimal amount) {
((WalletService) AopContext.currentProxy()).withdrawMoney(amount);
}
Leaky abstraction – business code now depends on Spring AOP internals. Use sparingly.
5. Switch to AspectJ weaving
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
Effective for many beans, but introduces significant complexity; usually not worth it for a few cases.
Summary
- External call → Proxy → Aspects run → Business logic.
- Internal call → Raw
thisobject → Business logic (no aspects).
To avoid the paradox, reorganize your code, inject the proxy if necessary, or use TransactionTemplate. Never rely on @Transactional for self‑invoked methods.