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.
🟢 Junior Level
If you call a @Transactional method from another method of the same class — the 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
@Transactionalworks only when called from outside the classthis.method()— always bypasses proxy- Private/protected methods never intercepted
How to fix
- Extract method to another bean (best option)
- Self-injection: inject service into itself with
@Lazy - TransactionTemplate: programmatic transaction management
When self-invocation is NOT a problem
- Method doesn’t need transaction — if @Transactional is for consistency but method is read-only
- Outer call already in transaction — if outerMethod is already @Transactional, innerMethod works in same transaction (propagation = REQUIRED by default)
- Using AspectJ mode — compile-time/load-time weaving intercepts
thiscalls
🟡 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
A. Extract method to another bean (Recommended)
@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)
-
@Transactionalon 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. -
Nested self-invocation:
methodA()→this.methodB()(no tx) →this.methodC()(has@Transactional). Neither B nor C will be transactional. Entire chain bypassed. -
@Transactional(propagation = REQUIRES_NEW)with self-invocation: EvenREQUIRES_NEWwon’t work onthis.method()call. New transaction won’t open — method executes in caller context (without transaction if caller also not in tx). -
Self-invocation and
@Async:@Asyncmethods also go through proxy.this.asyncMethod()— method executes synchronously, not asynchronously. Double proxy bypass: both transaction and async. -
Constructor calls: If constructor calls
@Transactionalmethod — 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
- 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.
- Avoid
AopContext.currentProxy(): Tight coupling, ThreadLocal issues, performance overhead. Not for production. - Self-invocation = code smell: If you’re fighting self-invocation — the service probably does too much. Split by SRP.
- Test self-invocation behavior: Unit tests with Mockito don’t catch this problem (Mockito mocks go through proxy). Need Spring integration test.
- Consider programmatic transactions: For complex conditional logic,
TransactionTemplateis cleaner than fighting with proxy. - 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
thisbypasses 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? —
thisalways 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]]