skip to content

What happens when one method in a Spring bean calls another @Transactional method on the same object using this.method()?

level: juniorimportance: must knowfreq 70%

answer

  1. this.method() = skips proxy
  2. annotation silently ignored
  3. fixes: self-inject / separate bean / AspectJ
  4. same for @Async, @Cacheable
  5. external call OK, internal call NOT

basics

~10 s

The @Transactional is ignored. An internal this.method() call skips Spring's proxy, so no transaction is started for the inner method. The annotation silently does nothing.

solid answer

~40 s

Spring implements @Transactional with a proxy: at runtime the injected bean is really a wrapper that opens a transaction before delegating to your real object. When external code calls the bean, it goes through this proxy. But when one method calls another method on the same instance via this.method(), the call goes straight to the real object and never touches the proxy — so the transactional wrapper code never runs. The inner @Transactional (or its propagation setting, e.g. REQUIRES_NEW) is silently ignored: no new transaction, no rollback rules, nothing. The code compiles and runs, which makes the bug easy to miss. This 'self-invocation' problem is the classic gotcha; the same limitation affects other proxy-based annotations like @Async and @Cacheable.

code

java · 16 lines
java
@Service
public class OrderService {

    @Transactional
    public void placeOrder() {
        // Internal call — goes to the raw object, NOT the proxy.
        // audit()'s @Transactional(REQUIRES_NEW) is IGNORED.
        audit();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void audit() {
        // Expected: brand-new independent transaction.
        // Reality (via self-invocation): no new transaction started.
    }
}

go deeper

for a junior

Must know the one-liner: internal this.method() call means @Transactional is ignored, no error thrown.

for a middle

Should explain it is the proxy that isn't crossed, and name at least one fix.

for a senior

Should discuss all three fixes and the tradeoffs, plus that it affects @Async/@Cacheable too.

for a principal

Should reason about design (prefer bean extraction), AspectJ weaving tradeoffs, and detecting these silently-broken cases in a codebase.

## The setup `@Transactional` is not magic on the method itself — Spring makes it work by wrapping your bean in a **proxy**. When you annotate a class or method with `@Transactional`, Spring creates a proxy object that sits *in front of* your real bean. Everywhere you `@Autowired` that bean, you actually receive the **proxy**, not the raw object. The proxy's job: before it delegates to your real method, it asks the `PlatformTransactionManager` to begin a transaction (or join an existing one), and afterwards it commits or rolls back. ## Why self-invocation breaks it The proxy only runs its transaction logic when a call **passes through the proxy**. Two cases: - **External call** — some other bean calls `service.doWork()`. That reference is the proxy, so the transactional wrapper runs. Works. - **Internal call (self-invocation)** — inside `doWork()` you call `this.doInner()` (or just `doInner()`, which is implicitly `this.`). Here `this` is the **raw target object**, not the proxy. The call never leaves the object, never crosses the proxy boundary, so the transaction advice for `doInner()` **is skipped entirely**. So if `doInner()` is `@Transactional(propagation = REQUIRES_NEW)` and you expected a fresh independent transaction, you get nothing — `doInner()` just runs in whatever transaction context `doWork()` already has (possibly none). ```java @Service public class OrderService { @Transactional public void placeOrder() { audit(); // == this.audit(); bypasses proxy } @Transactional(propagation = Propagation.REQUIRES_NEW) public void audit() { // NO new transaction is started here! } } ``` ## Why it is so dangerous It fails **silently**. No exception, no warning at startup — the annotation is simply ignored. Symptoms show up later as: data not rolled back when you expected isolation, an audit row committed together with the failing main transaction (or vice versa), or a read-only flag not applied. ## The three standard fixes 1. **Self-injection** — inject the bean into itself and call through the injected reference (which is the proxy): ```java @Autowired private OrderService self; public void placeOrder() { self.audit(); } ``` Works because `self` is the proxy. Slightly ugly; can be done with `@Lazy` to avoid circular-reference issues, or via `AopContext.currentProxy()` (requires `exposeProxy = true`). 2. **Extract to a separate bean** — move `audit()` into its own `@Service` and inject that. The cross-bean call goes through *its* proxy. This is the cleanest, most-recommended fix and usually improves the design. 3. **Switch to AspectJ weaving** (`@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)` + load-time or compile-time weaving). AspectJ weaves the transaction advice directly into the bytecode of the class instead of using a proxy, so *every* call — including `this.method()` — is intercepted. This removes the limitation entirely but adds build/agent complexity. ## When to use which - Prefer **separate bean** by default — best design, no tricks. - **Self-injection / AopContext** when splitting the class is awkward. - **AspectJ** when you have many self-calls or need the limitation gone project-wide and can accept weaving setup. ## Related gotchas The same proxy limitation applies to `@Async`, `@Cacheable`, `@Retryable`, `@PreAuthorize`, and other proxy-based aspects — internal self-calls bypass all of them. Also note: proxying only works on `public` methods (for CGLIB/JDK proxies); `private`/`final` methods are never proxied either.

  • Does the problem also occur if placeOrder() is NOT transactional but audit() is?
    Yes. The issue is about which annotated method is entered via the proxy. Calling this.audit() from any method on the same object bypasses the proxy, so audit()'s @Transactional is ignored regardless of whether the caller is transactional.
  • How would you confirm at runtime that a transaction was NOT started?
    Enable TRACE logging for org.springframework.transaction, or call TransactionSynchronizationManager.isActualTransactionActive() inside the inner method — it returns false when the transaction was skipped.

saying these in an interview costs you the question

  • Claiming @Transactional works on any method call regardless of who calls it
  • Thinking a private @Transactional method still gets its own transaction
  • Believing the bug throws an exception rather than failing silently
  • Saying making the method public fixes self-invocation (it does not)

context