skip to content

Why does @Transactional only work on public methods in proxy mode, and why is a self-invocation ignored?

level: seniorimportance: must knowfreq 75%

answer

  1. proxy wraps the reference caller holds
  2. public-only; protected/private silently ignored
  3. this.inner() bypasses proxy
  4. fix: extract bean / self-inject / AopContext / AspectJ
  5. final & Kotlin default-final can't be CGLIB-proxied

basics

~20 s

In the default proxy mode, the transaction lives in a proxy that wraps the bean. The proxy can only intercept public methods, and only calls coming from outside the object. A method calling another method on 'this' skips the proxy, so @Transactional is ignored.

solid answer

~40 s

Default proxy mode implements @Transactional as an AOP proxy around the target bean. Two limits follow. First, the proxy applies transactional advice only to public methods — AnnotationTransactionAttributeSource ignores @Transactional on protected, private, or package-private methods (silently, with no error). Second, only calls that go through the proxy are advised. When method A on the bean calls this.B(), that internal call goes straight to the target object, bypassing the proxy entirely, so B's @Transactional does nothing — the classic self-invocation trap. Fixes: split B into a separate injected bean (so the call crosses a proxy boundary), inject self-reference, use AopContext.currentProxy(), or switch to AdviceMode.ASPECTJ weaving, which advises the bytecode directly and honors non-public methods and self-calls.

code

java · 33 lines
java
@Service
public class OrderService {

    // BROKEN: self-invocation. createOrder() calls this.audit() on the raw target,
    // so audit()'s REQUIRES_NEW is ignored -- no separate transaction.
    @Transactional
    public void createOrder(Order o) {
        save(o);
        audit(o);              // internal call -> bypasses proxy
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void audit(Order o) { /* wanted its own tx, but won't get one here */ }
}

// FIX: extract audit into its own bean so the call crosses a proxy boundary.
@Service
public class OrderService2 {
    private final AuditService audit;
    OrderService2(AuditService audit) { this.audit = audit; }

    @Transactional
    public void createOrder(Order o) {
        save(o);
        audit.record(o);       // goes through AuditService's proxy -> REQUIRES_NEW honored
    }
}

@Service
class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(Order o) { /* runs in its own transaction */ }
}

go deeper

for a junior

Know that @Transactional belongs on public methods called from outside the class.

for a middle

Explain the proxy wrapping and that internal this.method() calls skip it.

for a senior

Enumerate fixes (extract bean, self-inject, AopContext, AspectJ) and the final/Kotlin proxying constraints.

for a principal

Weigh proxy vs AspectJ weaving org-wide, and design service boundaries so transactional units align with proxy boundaries.

## The proxy model in one picture In default `AdviceMode.PROXY`, Spring does not modify your class. It creates a **proxy** object that wraps your bean. Callers get the proxy; the proxy runs the `TransactionInterceptor`, then forwards to the real (target) instance. Every guarantee of `@Transactional` depends on the call passing *through* that proxy. ## Limit 1: public methods only Spring uses two proxy technologies: - **JDK dynamic proxies** — the proxy implements the bean's interfaces, and interface methods are public by definition. - **CGLIB proxies** — a runtime subclass that overrides methods; it can only override methods visible to a subclass and, by Spring's design, advises only **public** ones. `AnnotationTransactionAttributeSource` is configured `publicMethodsOnly = true` for this mode, so `@Transactional` on a `protected`, `private`, or package-private method is **silently ignored** — no exception, no log by default. This is a frequent source of 'my transaction isn't starting' bugs. ## Limit 2: self-invocation bypasses the proxy Consider: ``` @Transactional public void outer() { inner(); } @Transactional(propagation = REQUIRES_NEW) public void inner() { ... } ``` When an external caller invokes `outer()`, it goes through the proxy (good). But inside `outer()`, `inner()` is really `this.inner()` — a plain Java call on the target object. It never touches the proxy, so the `REQUIRES_NEW` on `inner()` is **ignored**; `inner()` just runs in `outer()`'s transaction (or none, if `outer` had none). This is the **self-invocation** problem. ## Why it happens AOP proxies wrap the *reference the caller holds*. Inside the object, `this` is the raw target, not the proxy. Spring cannot intercept a call it never sees. ## Fixes 1. **Move the method to another bean.** Put `inner()` on a separate `@Service`; inject it. The call now crosses a proxy boundary. (Cleanest, most common.) 2. **Self-injection.** Inject the bean into itself (`@Autowired private MyService self;`) and call `self.inner()`. Works because `self` is the proxy. Slightly smelly. 3. **`AopContext.currentProxy()`.** Call `((MyService) AopContext.currentProxy()).inner()`. Requires `@EnableAspectJAutoProxy(exposeProxy = true)` / `proxyTargetClass` exposure. Rarely worth it. 4. **`AdviceMode.ASPECTJ`.** AspectJ weaves advice into the bytecode of the class itself (compile-time or load-time weaving), not via a wrapper proxy. It advises non-public methods and honors self-invocation, at the cost of a weaving setup. ## Related gotchas - **`final` methods/classes** can't be proxied by CGLIB (can't subclass/override) → advice silently lost. Kotlin classes/methods are `final` by default, which is why Spring's Kotlin support opens them via the `all-open`/`kotlin-spring` plugin. - Invoking a transactional method from the constructor or from an `@PostConstruct` may run before the proxy is fully in play. ## When to care Any time you refactor a transactional method to be called internally, or annotate a helper method — verify the call actually crosses the proxy, or the transaction quietly won't apply.

  • You put @Transactional on a private method. Does it work? Any warning?
    No, it does not work in proxy mode — the attribute source ignores non-public methods, and by default there's no error or log. The method just runs without a managed transaction.
  • Name two ways to make a self-invoked transactional method actually get its transaction.
    Extract it into a separate injected bean so the call crosses a proxy boundary, or switch to AdviceMode.ASPECTJ weaving. Other options: self-injection (call via the injected proxy) or AopContext.currentProxy().
  • Why do Kotlin Spring services need the kotlin-spring/all-open compiler plugin here?
    Kotlin classes and methods are final by default, and CGLIB can't subclass/override final members to insert transactional advice. The plugin opens Spring-annotated classes so they can be proxied.

saying these in an interview costs you the question

  • Believing @Transactional on a private method still starts a transaction
  • Not knowing self-invocation bypasses the proxy
  • Thinking a warning is always logged when advice is skipped (it is silent by default)
  • Claiming AspectJ mode has the same self-invocation limitation as proxy mode

context