skip to content

Why does an internal self-invoked call to a @Transactional method sometimes run without a transaction?

level: middleimportance: must knowfreq 70%

answer

  1. this.method() skips the proxy
  2. Advice only fires through proxy
  3. Refactor to two beans / self-inject
  4. AopContext.currentProxy + exposeProxy
  5. AspectJ weaving catches self-calls

basics

~10 s

Because @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 s

Spring 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
java
@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

for a junior

Recognize the symptom: internal call to a @Transactional method doesn't open a transaction.

for a middle

Explain the proxy-bypass mechanism and give at least one correct fix.

for a senior

Compare fixes (separate bean vs self-inject vs AopContext vs AspectJ) and their trade-offs.

for a principal

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

context