Question 22 · Section 11

What happens when calling @Transactional method from another method of the same class?

If you call a @Transactional method from another method of the same class — the annotation is ignored and the transaction does not start.

Language versions: English Russian Ukrainian

🟢 Junior Level

If you call a @Transactional method from another method of the same classthe annotation is ignored and the transaction does not start.

This is called the Self-invocation problem.

Simple example

@Service
public class OrderService {

    public void createOrderExternal() {
        saveOrder();  // Call within class — @Transactional does NOT work!
    }

    @Transactional
    public void saveOrder() {
        // This logic executes WITHOUT a transaction
        orderRepository.save(order);
    }
}

Analogy

Imagine you’re calling a friend through a secretary (Proxy). The secretary records the conversation (manages the transaction). But if you shout to your friend through the wall (this) — the secretary knows nothing about the conversation.

Why this happens

Spring manages transactions through a Proxy object (wrapper). When external code calls a method — it goes through the proxy. But when a method calls another method of the same class — the call goes directly via this, bypassing the proxy.

Mechanism: Spring creates a proxy object that intercepts calls from outside. But when method A calls method B of the same object, this.methodB() goes directly — proxy not involved, TransactionInterceptor doesn’t fire.

Important rules

  • @Transactional works only when called from outside the class
  • this.method() — always bypasses proxy
  • Private/protected methods never intercepted

How to fix

  1. Extract method to another bean (best option)
  2. Self-injection: inject service into itself with @Lazy
  3. TransactionTemplate: programmatic transaction management

When self-invocation is NOT a problem

  1. Method doesn’t need transaction — if @Transactional is for consistency but method is read-only
  2. Outer call already in transaction — if outerMethod is already @Transactional, innerMethod works in same transaction (propagation = REQUIRED by default)
  3. Using AspectJ mode — compile-time/load-time weaving intercepts this calls

🟡 Middle Level

Proxy Mechanism

External call:
Client → Proxy → TransactionInterceptor → TransactionManager → Real method
                       ↓
               Open transaction → Execute → COMMIT/ROLLBACK

Internal call (this):
Method A → this.method B() → Real method (bypasses Proxy!)
                                ↓
                          No transaction!

Spring creates a wrapper bean (Proxy) around your object. When an external client calls a method, it calls it on the Proxy. Proxy intercepts the call, opens transaction, and passes control.

Call via this goes directly to the real object — proxy completely bypassed.

Solutions

@Service
public class OrderService {
    @Autowired
    private OrderRepositoryService orderRepositoryService;

    public void createOrderExternal() {
        orderRepositoryService.saveOrder();  // Through another bean — works!
    }
}

@Service
public class OrderRepositoryService {
    @Transactional
    public void saveOrder() {
        // Now transaction works
    }
}

B. Self-injection

@Service
public class OrderService {

    @Autowired
    @Lazy  // To avoid circular dependency
    private OrderService self;

    public void createOrderExternal() {
        self.saveOrder();  // Through proxy — transaction works!
    }

    @Transactional
    public void saveOrder() {
        // Now transaction works
    }
}

C. TransactionTemplate

@Service
public class OrderService {

    @Autowired
    private TransactionTemplate transactionTemplate;

    public void createOrderExternal() {
        transactionTemplate.execute(status -> {
            saveOrder();  // Programmatic transaction
            return null;
        });
    }

    public void saveOrder() {
        // Executed in transaction via TransactionTemplate
    }
}

D. AspectJ

Switching to AspectJ weaving modifies bytecode at compile time, and calls within the class are intercepted correctly. Requires complex setup.

Common mistakes

Mistake What happens How to fix
Not knowing about self-invocation Code works in tests, fails in production Extract to separate bean
Self-injection without @Lazy BeanCurrentlyInCreationException Add @Lazy
AopContext.currentProxy() without exposeProxy IllegalStateException: Cannot find current proxy @EnableAspectJAutoProxy(exposeProxy = true)
Mixing approaches Unpredictable behavior Choose one approach

Solution comparison

Solution Complexity Self-invocation Private methods Recommended
Extract to separate bean Low ✅ Best
Self-injection (@Lazy) Low Good
AopContext.currentProxy() Medium OK
TransactionTemplate Medium Good for complex logic
AspectJ weaving High Only if needed

When NOT to use solutions

Situation Why Alternative
Method doesn’t need own transaction Self-invocation not a problem if caller already in tx Leave as is
Simple read-only method ReadOnly doesn’t require complex transaction readOnly = true on caller
Method is utility, not business logic Doesn’t belong in service layer Extract to util class

🔴 Senior Level

Proxy-Based AOP — Root Cause Analysis

How Spring Creates Proxies

JDK Dynamic Proxy:

UserService proxy = (UserService) Proxy.newProxyInstance(
    classLoader,
    new Class[]{UserService.class},
    new TransactionalInvocationHandler(target, interceptor)
);

CGLIB:

UserService proxy = (UserService) Enhancer.create(
    UserService.class,
    new TransactionalMethodInterceptor(target, interceptor)
);

In both cases, proxy wraps the target object. External calls go through proxy → interceptor → target. Internal calls (this.method()) go directly: target → target. This is true for both JDK Dynamic Proxy and CGLIB — in both cases this refers to the target object, not the proxy. CGLIB creates a subclass, but this inside subclass methods still points to the target object.

The this Reference Problem

public class OrderService {
    // this = OrderService@123 (the TARGET object, not the proxy)

    public void createOrderExternal() {
        // this.saveOrder() calls target directly
        // Bypasses: OrderServiceProxy@456.saveOrder()
        saveOrder();
    }
}

this always points to actual object, never to proxy. No way for Spring to intercept this.*() calls with standard AOP.

Solution Deep Dive

Solution 1: Self-Injection with @Lazy

@Service
public class OrderService {
    @Autowired
    @Lazy
    private OrderService self;

    public void createOrderExternal() {
        // self = proxy (OrderService$$EnhancerBySpringCGLIB$$...)
        self.saveOrder();  // Goes through proxy → interceptor
    }
}

Why @Lazy: Without @Lazy, Spring tries to inject OrderService during its creation → circular dependency error. @Lazy creates a lazy proxy that resolves the real bean on first use.

Solution 2: AopContext.currentProxy()

@EnableAspectJAutoProxy(exposeProxy = true)  // Required!

@Service
public class OrderService {
    public void createOrderExternal() {
        ((OrderService) AopContext.currentProxy()).saveOrder();
    }
}

How it works: exposeProxy = true saves proxy in ThreadLocal. AopContext.currentProxy() retrieves it.

Drawbacks: Tight coupling to Spring API, ThreadLocal — doesn’t work across threads, exposeProxy has performance overhead.

// exposeProxy = true adds overhead on every call (ThreadLocal). // Don’t use in highload. Alternative: extract method to separate bean.

Solution 3: Programmatic Transactions

@Service
public class OrderService {
    @Autowired
    private PlatformTransactionManager transactionManager;

    public void createOrderExternal() {
        TransactionTemplate template = new TransactionTemplate(transactionManager);
        template.execute(status -> {
            saveOrder();  // In transaction
            return null;
        });
    }
}

Advantages: No proxy needed, explicit transaction boundaries, works in any context. Disadvantages: More verbose, mixing transaction management with business logic.

AspectJ Weaving — The Nuclear Option

Compile-Time Weaving

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>aspectj-maven-plugin</artifactId>
    <configuration>
        <aspectLibraries>
            <aspectLibrary>
                <groupId>org.springframework</groupId>
                <artifactId>spring-aspects</artifactId>
            </aspectLibrary>
        </aspectLibraries>
    </configuration>
</plugin>

Modifies bytecode at compile time:

// BEFORE
public void createOrderExternal() { saveOrder(); }

// AFTER (AspectJ-woven)
public void createOrderExternal() {
    TransactionInterceptor.invoke(this, "saveOrder", ...);
    saveOrder();
}

Benefits: Self-invocation works, private/protected methods can be @Transactional, no proxy limitations. Drawbacks: Complex build config, bytecode modification (debugging harder), limited IDE support, not compatible with all Spring Boot features.

Edge Cases (minimum 3)

  1. @Transactional on interface + CGLIB: If proxy is CGLIB (class-based), annotation on interface not visible. Self-invocation already doesn’t work, plus annotation not applied to proxy. Double problem.

  2. Nested self-invocation: methodA()this.methodB() (no tx) → this.methodC() (has @Transactional). Neither B nor C will be transactional. Entire chain bypassed.

  3. @Transactional(propagation = REQUIRES_NEW) with self-invocation: Even REQUIRES_NEW won’t work on this.method() call. New transaction won’t open — method executes in caller context (without transaction if caller also not in tx).

  4. Self-invocation and @Async: @Async methods also go through proxy. this.asyncMethod() — method executes synchronously, not asynchronously. Double proxy bypass: both transaction and async.

  5. Constructor calls: If constructor calls @Transactional method — proxy not yet created (bean initialization not complete). Call always goes directly.

JDK Proxy vs CGLIB for Self-Invocation

Characteristic JDK Dynamic Proxy CGLIB
Self-invocation ❌ Doesn’t work ❌ Doesn’t work
Final methods N/A (interface) ❌ Not intercepted
Private methods ❌ Not intercepted ❌ Not intercepted
Performance overhead Low Medium
Bytecode generation No Yes (generates subclass)

Both proxy types do not solve self-invocation problem. Only AspectJ weaving solves it.

Performance Numbers

Operation Time
Proxy invocation overhead ~1-2 μs
AopContext.currentProxy() lookup ~0.5 μs (ThreadLocal get)
exposeProxy overhead ~0.1 μs per call
Self-injection (@Lazy) resolution ~1 μs (first call), 0 (cached)

Memory Implications

  • exposeProxy = true: ThreadLocal for each proxy — ~1 KB per active thread
  • Self-injection: extra proxy reference in bean — negligible (~8 bytes)
  • AspectJ weaving: Bytecode modification — larger class files, no runtime memory impact

Thread Safety

AopContext.currentProxy() uses ThreadLocal — doesn’t work across threads. @Async methods in another thread won’t get proxy from caller’s ThreadLocal. Self-injection proxy — thread-safe (singleton proxy reference).

Production War Story

Order processing service: createOrder() called this.validateOrder() (with @Transactional). Validation wrote audit log to DB. In tests (single thread, no contention) — worked. In production — audit log not written, because transaction didn’t open. Data was lost. Fix: validateOrder() extracted to separate OrderValidationService.

Monitoring

Detect self-invocation at runtime:

@Bean
public BeanPostProcessor selfInvocationDetector() {
    return new BeanPostProcessor() {
        @Override
        public Object postProcessAfterInitialization(Object bean, String beanName) {
            if (AopUtils.isAopProxy(bean)) {
                // Log: this bean has a proxy — self-invocation will bypass it
            }
            return bean;
        }
    };
}

Actuator:

GET /actuator/beans  # Shows which beans are wrapped in proxy

Highload Best Practices

  1. Extract to separate bean — always: This is not a workaround, it’s proper separation of concerns. If a method needs its own transaction — it should have its own responsibility.
  2. Avoid AopContext.currentProxy(): Tight coupling, ThreadLocal issues, performance overhead. Not for production.
  3. Self-invocation = code smell: If you’re fighting self-invocation — the service probably does too much. Split by SRP.
  4. Test self-invocation behavior: Unit tests with Mockito don’t catch this problem (Mockito mocks go through proxy). Need Spring integration test.
  5. Consider programmatic transactions: For complex conditional logic, TransactionTemplate is cleaner than fighting with proxy.
  6. Architecture rule: Introduce ArchUnit test: noClass().should().callMethodWhere(target declaresAnnotation(Transactional.class)).fromSameClass().

🎯 Interview Cheat Sheet

Must know:

  • Self-invocation problem: calling @Transactional method via this bypasses Spring Proxy
  • Spring manages transactions through AOP Proxy (JDK Dynamic Proxy or CGLIB)
  • this.method() goes directly to target object — TransactionInterceptor doesn’t fire
  • 4 solutions: extract to another bean (best), self-injection @Lazy, AopContext.currentProxy(), TransactionTemplate
  • REQUIRES_NEW also does NOT work with self-invocation — new transaction won’t open
  • @Async with self-invocation executes synchronously — double proxy bypass

Common follow-up questions:

  • Why doesn’t proxy intercept this calls? — this always references target object, not proxy
  • JDK Proxy vs CGLIB in self-invocation context? — Both do NOT solve self-invocation problem
  • When is self-invocation NOT a problem? — If caller already in @Transactional (propagation REQUIRED joins)
  • Why doesn’t Mockito catch this problem? — Mockito mocks = proxy, self-invocation works on mock

Red flags (DO NOT say):

  • “Self-invocation works in production” — proxy bypassed, transaction doesn’t open
  • “REQUIRES_NEW solves self-invocation” — REQUIRES_NEW also requires proxy
  • “AopContext.currentProxy() = best practice” — tight coupling to Spring API, ThreadLocal overhead

Related topics:

  • [[16. What is @Transactional annotation]]
  • [[13. What is Propagation in Spring]]
  • [[17. At what level can you use @Transactional]]
  • [[15. What is the difference between REQUIRED and REQUIRES_NEW]]