Why Transactional does not work with self-invocation
When this happens, the @Transactional annotation (as well as @Async, @Cacheable, @PreAuthorize, etc.) on the invoked method is completely ignored, and no transaction is opened.
🟢 Junior Level
Self-invocation occurs when a method within a Spring bean calls another method belonging to the exact same class using the implicit or explicit this reference.
When this happens, the @Transactional annotation (as well as @Async, @Cacheable, @PreAuthorize, etc.) on the invoked method is completely ignored, and no transaction is opened.
Code Example of the Defect
package com.example.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
// Outer non-transactional method
public void processOrder(Long orderId) {
System.out.println("Step 1: Preparing order...");
// ❌ SELF-INVOCATION BUG: Calling another method on 'this' directly!
saveToDatabase(orderId);
}
// Target method requiring a database transaction
@Transactional
public void saveToDatabase(Long orderId) {
System.out.println("Step 2: Executing database updates WITHOUT a transaction!");
// SQL updates execute in standard autocommit=true JDBC mode
}
}
The Receptionist Analogy
Imagine the Spring transactional proxy is a receptionist guarding the entrance to a corporate executive’s office, checking visitor credentials and issuing security badges before anyone enters.
- When an outside client (e.g. a Controller) calls
OrderService, the request passes through the receptionist (the proxy), who initiates the database transaction. - But when the executive inside the office picks up a pen from their own desk (an internal method call within the class), the receptionist is never involved. The call executes directly on the raw instance without any security checks or transaction boundaries.
🟡 Middle Level
Architectural Root Cause: JVM invokevirtual Bypasses the Proxy
Spring AOP is strictly proxy-based. When Spring injects OrderService into a controller or another service, it does not inject the raw Java instance; it injects a dynamically generated proxy object (OrderService$$SpringCGLIB$$0 or $Proxy42).
EXTERNAL CALL (Works Normally):
[ Client ] ──> [ OrderService Proxy ] ──> [ TransactionInterceptor ] ──> [ Target OrderService Instance ]
(Opens DB Transaction) (saveToDatabase)
INTERNAL SELF-INVOCATION (FAILS SILENTLY):
[ Client ] ──> [ OrderService Proxy ]
│
▼
[ Target OrderService Instance ]
│
└──> processOrder()
│
└── (this.saveToDatabase()) ──► Bypasses Proxy & Interceptors!
Step-by-Step Invocation Breakdown:
- An external client calls
orderService.processOrder(). - The proxy intercepts the call. Since
processOrder()has no transactional annotations, the proxy simply delegates the call directly to the target object. - Inside
processOrder(), the code callssaveToDatabase(). - At the JVM bytecode level, this compiles into an
invokevirtualinstruction targeted at thethispointer. - The
thispointer references the raw target instance in heap memory, not the proxy wrapper! - The invocation dispatches directly to the target instance’s virtual method table (vtable), completely bypassing
TransactionInterceptor. The database operation executes without a transaction.
Why CGLIB Subclassing Does Not Solve Self-Invocation
A frequent interview misconception is believing that because CGLIB generates a subclass, it must override all methods and intercept internal calls.
Why this is false:
- CGLIB generates a subclass:
OrderService$$SpringCGLIB$$0 extends OrderService. - The proxy subclass overrides
saveToDatabase()to invoke theMethodInterceptorchain. - However, business logic inside
processOrder()executes within the original target instance. - When
processOrder()executesthis.saveToDatabase(), thethisreference inside that execution frame refers to the target object itself, not the CGLIB proxy subclass. The call never reaches the overridden proxy method!
All AOP Annotations Broken by Self-Invocation
The self-invocation limitation affects every single proxy-based AOP feature in Spring:
| Annotation | Silent Failure Consequence |
|---|---|
@Transactional |
Executes in standard autocommit mode; REQUIRES_NEW or NESTED rules are completely ignored |
@Async |
Executes synchronously on the caller’s thread instead of offloading to a thread pool |
@Cacheable / @CacheEvict |
Cache lookup is bypassed; queries the database on every invocation |
@PreAuthorize / @Secured |
Security role checks are skipped (Critical Security Vulnerability) |
@Retryable |
Retries are not attempted when exceptions occur |
@Validated |
Method argument validation is bypassed |
🔴 Senior Level
Distortion of Propagation Semantics in Self-Invocation
A severe production bug occurs when an outer method is already transactional, and it invokes an internal method marked with a specialized propagation policy like REQUIRES_NEW:
@Service
public class PaymentProcessingService {
@Transactional // Outer Transaction 1 (REQUIRED)
public void processPayment(Payment payment) {
chargeCustomerCard(payment);
try {
// Self-invocation: Attempting to log audit trail in an independent transaction
recordAuditLog(payment);
} catch (Exception ex) {
// Attempting to swallow audit log failure so the payment still completes
log.warn("Audit logging failed, but continuing payment flow", ex);
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordAuditLog(Payment payment) {
// ❌ CRITICAL DEFECT: REQUIRES_NEW IS COMPLETELY IGNORED!
// Executes within the existing Outer Transaction 1!
auditRepository.save(new AuditRecord(payment));
}
}
Production Catastrophe:
- The developer expects
recordAuditLog()to run in an isolated transaction so that if audit logging throws an exception, the payment itself can still succeed. - Because of self-invocation,
recordAuditLog()runs directly inside Outer Transaction 1. - When
auditRepository.save()throws an exception, the underlying HibernateSessionis marked as Rollback-Only. - Even though
processPayment()caught the exception in atry-catchblock, whenprocessPayment()exits, the transaction manager attempts to commit and crashes with:org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only - The entire payment transaction is rolled back, corrupting business workflow!
Runtime Verification Using TransactionSynchronizationManager
You can programmatically verify whether an active transaction is physically bound to the executing thread:
import org.springframework.transaction.support.TransactionSynchronizationManager;
public void verifyTransactionState() {
boolean isTxActive = TransactionSynchronizationManager.isActualTransactionActive();
String currentTxName = TransactionSynchronizationManager.getCurrentTransactionName();
boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
System.out.println("Active Transaction: " + isTxActive); // false if self-invoked
System.out.println("Transaction Name: " + currentTxName);
System.out.println("Is Read-Only: " + isReadOnly);
}
Why AspectJ Does NOT Suffer From Self-Invocation
Unlike Spring AOP’s runtime proxies, full AspectJ relies on Bytecode Weaving (Compile-Time Weaving with ajc or Load-Time Weaving via -javaagent:aspectjweaver.jar):
- AspectJ injects advice bytecode directly into the
.classfile or loads modified bytecode into the JVM. - When
processOrder()callssaveToDatabase(), the cross-cutting instructions are already compiled into the method body or call site. - Because AspectJ does not rely on outer proxy wrappers, internal
thiscalls are intercepted seamlessly.
4 Tricky Questions
1. What happens if a non-transactional method calls a @Transactional(propagation = Propagation.REQUIRES_NEW) method within the same class?
Answer:
No transaction will be opened at all. The call executes via direct JVM invokevirtual on the this reference, completely bypassing the Spring AOP dynamic proxy. The method will execute without any transactional context, running each database statement in standard JDBC autocommit mode. The REQUIRES_NEW instruction is completely ignored.
2. Why does CGLIB proxying fail to intercept self-invocation even though the proxy extends the target class?
Answer:
While CGLIB generates a subclass (MyService$$SpringCGLIB$$0) that overrides methods with interceptor callbacks, the outer caller executes code inside an actual instance of the target class.
Inside the body of the calling method, the JVM executes calls using the this pointer. The this pointer points to the target object instance itself, not the CGLIB proxy wrapper. Therefore, the invocation resolves to the target class’s own virtual method table (vtable) and never reaches the overridden methods in the generated CGLIB subclass.
3. What happens if a method annotated with @Async is called via self-invocation? What are the thread execution implications?
Answer:
The method will execute synchronously on the caller’s thread, completely defeating the purpose of @Async.
Spring’s AsyncExecutionInterceptor is only triggered when calls pass through the proxy wrapper. Because self-invocation bypasses the proxy, the task is never submitted to the configured TaskExecutor thread pool. If the method takes 5 seconds to complete, the calling HTTP request thread will block for 5 seconds.
4. How does Spring behave when an outer @Transactional method catches an exception thrown by an inner self-invoked @Transactional(REQUIRES_NEW) method?
Answer: Because of self-invocation, the inner method does not open a new transaction; it executes within the outer transaction’s physical connection.
When the inner method throws a RuntimeException, the transactional interceptor does not catch it, but the underlying JPA/Hibernate Session marks the shared transaction as Rollback-Only. When the outer method catches the exception and attempts to commit, Spring detects the rollback-only flag and throws UnexpectedRollbackException. The outer transaction fails and rolls back completely despite the try-catch block.
🎯 Interview Cheat Sheet
30-Second Elevator Pitch
Self-invocation occurs when a method in a Spring bean calls another method within the same class via
this. Because Spring AOP relies on runtime dynamic proxies (CGLIB / JDK Proxy), cross-cutting concerns (@Transactional,@Async,@Cacheable,@PreAuthorize) are only intercepted when calls originate from outside the bean through the proxy. Internalthiscalls invoke the target method directly via JVM bytecode, completely bypassing the interceptor chain. This results in silent failures: transactions are not started, propagation rules (REQUIRES_NEW) are ignored, and async methods run synchronously. CGLIB does not fix this becausethispoints to the target instance, not the proxy subclass.
Self-Invocation Impact Summary
| AOP Feature | Normal (External Proxy Call) | Self-Invocation (this.method()) |
|---|---|---|
@Transactional |
Opens DB transaction, commits/rolls back | Runs in autocommit mode without transaction |
REQUIRES_NEW |
Suspends parent; opens independent new Tx | Runs inside parent Tx or no Tx |
@Async |
Offloaded to background thread pool | Runs synchronously on caller thread |
@Cacheable |
Checks cache; avoids database query | Bypasses cache; executes query every time |
@PreAuthorize |
Evaluates security rules / roles | Bypasses security check completely |
Red Flags (DO NOT Say)
- ❌ “CGLIB solves the self-invocation problem because it creates a subclass.” (Both JDK Proxy and CGLIB suffer identically from self-invocation because
thisresolves to the target instance). - ❌ “Annotating the class with
@Transactionalallows internal self-invocation to work.” (Class-level annotations only advise public methods invoked through the proxy from outside). - ❌ “Self-invocation throws an exception like
TransactionRequiredException.” (It fails silently without throwing any errors, leading to insidious production data bugs). - ❌ “This problem is unique to
@Transactional.” (It breaks all proxy-based AOP:@Async,@Cacheable,@Secured,@Retryable).