skip to content

Why does calling one @Transactional method from another method in the same class sometimes leave the transaction annotation ignored?

level: juniorimportance: must knowfreq 82%

answer

  1. proxy wraps bean, this.method() skips it
  2. internal call = raw target, no advice
  3. self-inject / separate bean / AopContext
  4. AspectJ weaving is the only true fix
  5. no error, silently ignored

basics

~10 s

Spring's @Transactional works through a proxy that wraps the bean. An internal call (this.method()) bypasses the proxy, so the transaction advice never runs. Only calls coming through the injected bean reference are intercepted.

solid answer

~40 s

Declarative features like @Transactional are applied by a proxy Spring puts around your bean. When another bean calls the method, the call goes through that proxy, which starts the transaction before delegating to your real object. But when a method inside the same class calls a sibling method with plain this.method(), the call targets the raw object directly, so the proxy — and its transaction advice — is skipped entirely. This is the self-invocation problem. It applies to all proxy-based declarative annotations: @Transactional, @Cacheable, @Async, @Retryable, @Validated. Fixes: move the annotated method into a separate bean, inject the bean into itself and call through the injected reference, or use AopContext.currentProxy(). The cleanest fix is usually redesign so the entry point is called externally.

code

java · 28 lines
java
@Service
public class OrderService {

    // Called externally -> goes through proxy -> transaction ignored below anyway
    public void placeOrder(Order o) {
        // this.save(o) is a raw call on the target object:
        // the @Transactional proxy is BYPASSED, no transaction is started.
        save(o);
    }

    @Transactional
    public void save(Order o) {
        repository.insert(o); // runs WITHOUT a transaction when reached via placeOrder()
    }
}

// Fix via self-injection:
@Service
public class OrderService2 {
    @Autowired private OrderService2 self; // injected reference IS the proxy

    public void placeOrder(Order o) {
        self.save(o); // now goes through the proxy -> @Transactional applies
    }

    @Transactional
    public void save(Order o) { repository.insert(o); }
}

go deeper

for a junior

Should know the proxy exists and that internal calls can skip @Transactional.

for a middle

Should articulate this.method() targets the raw object and list the standard fixes.

for a senior

Should connect it to all proxy-based annotations and reason about which fix fits which design.

for a principal

Should weigh AspectJ weaving vs restructuring, and understand the maintenance cost of self-injection/AopContext hacks.

## The mechanism Spring implements declarative annotations such as `@Transactional`, `@Cacheable`, `@Async`, `@Retryable`, `@Validated`, and method security using **AOP proxies**. When Spring detects one of these annotations on a bean, it does not put the logic inside your class — instead it wraps your bean in a **proxy object** that shares the same interface/type. The proxy holds a reference to your real object (the *target*). Every method call that arrives at the proxy first runs the **advice** (e.g. `TransactionInterceptor` opens a transaction), then delegates to the target, then runs the after-part (commit/rollback). Crucially, **only the proxy runs the advice.** Your real object knows nothing about transactions. ## Why self-invocation breaks it When another bean has your bean injected, it holds a reference to the **proxy**. Calling `service.doWork()` goes proxy → advice → target. Good. But inside your class, when `methodA()` calls `methodB()`, the compiler emits `this.methodB()`. `this` is the **raw target object**, not the proxy. So the call goes straight to the target, skipping the proxy and all advice. `@Transactional`/`@Cacheable`/`@Async` on `methodB` are silently ignored. No error, no warning — just no transaction, no caching, no async execution. ## Fixes 1. **Restructure (preferred):** put the annotated method in a *different* bean, so the call crosses a bean boundary and goes through that bean's proxy. 2. **Self-injection:** inject the bean into itself and call `self.methodB()`. The injected reference is the proxy. ```java @Autowired private MyService self; ``` 3. **AopContext.currentProxy():** requires `@EnableAspectJAutoProxy(exposeProxy = true)` (or `exposeProxy=true` on `@EnableTransactionManagement` is not a thing — use the proxy-exposure flag). Then `((MyService) AopContext.currentProxy()).methodB()`. 4. **Switch to AspectJ load-time/compile-time weaving** (`mode = AdviceMode.ASPECTJ`): the advice is woven into the bytecode of the class itself, so even `this.methodB()` is intercepted. This is the only fix that makes self-invocation *just work*, but it needs a weaving agent. ## Related gotchas - The proxy only wraps **Spring-managed beans**. A `new MyService()` is never advised. - Proxy-based advice cannot see **private** or (for CGLIB) **final** methods. - Public constructor logic runs before advice too. ## When to care Any time a `@Transactional`/`@Cacheable`/`@Async` method mysteriously doesn't take effect, self-invocation is the first suspect.

  • Name three ways to make an internal call actually honor @Transactional.
    Move the method to a separate bean so the call crosses a proxy boundary; self-inject the bean and call through the injected (proxy) reference; use AopContext.currentProxy() with exposeProxy=true; or switch to AspectJ weaving (mode=ASPECTJ) which is the only option that intercepts this.method() directly.
  • Does self-invocation only affect @Transactional?
    No. It affects every proxy-based declarative annotation: @Cacheable/@CacheEvict, @Async, @Retryable, @Validated, and method-level @PreAuthorize/@Secured. They all rely on the proxy, so all are bypassed on internal this.method() calls.

saying these in an interview costs you the question

  • Claiming @Transactional works regardless of how the method is called
  • Saying the fix is to make the method static or private (that makes it worse)
  • Believing Spring logs a warning when self-invocation skips advice

context