Why does an internal self-invoked call to a @Transactional method sometimes run without a transaction?
answer
- this.method() skips the proxy
- Advice only fires through proxy
- Refactor to two beans / self-inject
- AopContext.currentProxy + exposeProxy
- AspectJ weaving catches self-calls
basics
~10 sBecause @Transactional is applied by the proxy. When a bean calls its own method with this.method(), the call goes straight to the target and skips the proxy, so the transactional advice never runs.
solid answer
~40 sSpring AOP interception lives in a proxy that wraps the bean. Advice (like the transaction interceptor) only fires when a call arrives *through* the proxy — i.e. from another bean. When a method calls a sibling method on the same instance via `this.other()`, the JVM dispatches directly on the target object, bypassing the proxy entirely, so `@Transactional`, `@Cacheable`, `@Async` etc. on `other()` are ignored. This is a direct consequence of Spring AOP advising only method *executions reached through the proxy*. Fixes: split the two methods into separate beans so the call crosses a proxy boundary; self-inject the proxy and call through it; use `AopContext.currentProxy()` (needs `exposeProxy=true`); or switch to AspectJ load-time weaving, which weaves the class itself and catches self-invocation.
code
java · 20 lines@Service
public class OrderService {
// Self-injected proxy so internal calls cross the proxy boundary.
@Autowired
private OrderService self;
public void createOrder() {
// BROKEN: bypasses proxy, no new transaction on saveAudit()
// this.saveAudit();
// FIXED: goes through the proxy -> @Transactional applies
self.saveAudit();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAudit() {
// db writes run in their own transaction
}
}go deeper
Recognize the symptom: internal call to a @Transactional method doesn't open a transaction.
Explain the proxy-bypass mechanism and give at least one correct fix.
Compare fixes (separate bean vs self-inject vs AopContext vs AspectJ) and their trade-offs.
Advise on architecture: prefer bean boundaries; reserve AspectJ mode for systemic cases; note interaction with propagation semantics.
## The setup Spring's declarative features — `@Transactional`, `@Cacheable`, `@Async`, `@PreAuthorize`, custom `@Aspect` advice — are all implemented with **proxy-based Spring AOP**. At startup Spring wraps the target bean in a **proxy** (JDK dynamic proxy if it implements interfaces, otherwise a CGLIB subclass). The advice (e.g. the `TransactionInterceptor`) lives in that proxy. ## The mechanism of the bug Interception only happens when a call passes **through the proxy**. That's true when *another* bean holds the injected reference (which is actually the proxy) and calls a method on it. But consider: ```java @Service public class OrderService { public void createOrder() { // ... this.saveAudit(); // <-- self-invocation } @Transactional public void saveAudit() { /* db writes */ } } ``` Inside `createOrder()`, the call `this.saveAudit()` is dispatched on **`this`** — the raw target instance, not the proxy. The proxy is a *different* object that merely delegates to `this`. So the transactional advice around `saveAudit()` is **never invoked**; `saveAudit()` runs with whatever transaction context `createOrder()` had (often none). This is the classic **self-invocation** problem, and it stems directly from the fact that Spring AOP intercepts only method executions **reached via the proxy**. The same applies to `@Cacheable` (cache is bypassed on internal calls), `@Async` (runs synchronously), and any custom `@Around` aspect. ## How to detect it - A `@Transactional` method 'not working' only when called internally. - `@Async` method running on the caller's thread. - `@Cacheable` recomputing every time for internal calls. ## Fixes 1. **Refactor into two beans** (cleanest). Move `saveAudit()` into a separate `@Service`; inject it. The call now crosses a proxy boundary. 2. **Self-injection / call through the proxy:** ```java @Service public class OrderService { @Autowired private OrderService self; // injected proxy public void createOrder() { self.saveAudit(); } @Transactional public void saveAudit() { } } ``` `self` is the proxy, so advice fires. (Guard against circular-reference issues; `@Lazy` may help.) 3. **`AopContext.currentProxy()`** — set `@EnableAspectJAutoProxy(exposeProxy = true)` (or `proxyTargetClass`+`exposeProxy`) and call `((OrderService) AopContext.currentProxy()).saveAudit();`. Works but ties code to Spring's AOP API. 4. **AspectJ load-time weaving** — because AspectJ weaves the **bytecode of the class itself** rather than wrapping it in a proxy, self-invocation *is* intercepted. Enable with `@EnableLoadTimeWeaving` + the `spring-instrument` agent, and set transaction management mode to `ASPECTJ` (`@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)`). ## Related gotcha: visibility Proxy-based `@Transactional` also only works on **public** methods (CGLIB can override protected/public, but Spring's transaction support ignores non-public by default). Private/final methods and self-invocation together are the top three 'my annotation silently does nothing' causes. ## When to accept vs. fix For most services, refactoring to separate beans is the idiomatic fix and keeps you on simple proxy AOP. Reach for AspectJ mode only if self-invocation interception is pervasive and refactoring is impractical.
- Does making saveAudit() private fix or worsen the problem?Worsens it — proxy-based Spring AOP can't advise private methods at all, so @Transactional would never apply regardless of how it's called. It must be public and invoked through the proxy.
- Why does AspectJ load-time weaving solve self-invocation?AspectJ weaves the advice into the target class's own bytecode instead of using a wrapper proxy, so even a `this.method()` call executes the woven advice. There's no separate proxy object to bypass.
saying these in an interview costs you the question
- Saying self-invocation works fine and the transaction just 'joins' correctly
- Suggesting making the method private to 'encapsulate' it (breaks advice entirely)
- Believing @Async on an internally-called method still runs on another thread