🍃 Section 5 · Question #18

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:

  1. An external client calls orderService.processOrder().
  2. The proxy intercepts the call. Since processOrder() has no transactional annotations, the proxy simply delegates the call directly to the target object.
  3. Inside processOrder(), the code calls saveToDatabase().
  4. At the JVM bytecode level, this compiles into an invokevirtual instruction targeted at the this pointer.
  5. The this pointer references the raw target instance in heap memory, not the proxy wrapper!
  6. 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 the MethodInterceptor chain.
  • However, business logic inside processOrder() executes within the original target instance.
  • When processOrder() executes this.saveToDatabase(), the this reference 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:

  1. The developer expects recordAuditLog() to run in an isolated transaction so that if audit logging throws an exception, the payment itself can still succeed.
  2. Because of self-invocation, recordAuditLog() runs directly inside Outer Transaction 1.
  3. When auditRepository.save() throws an exception, the underlying Hibernate Session is marked as Rollback-Only.
  4. Even though processPayment() caught the exception in a try-catch block, when processPayment() 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
  5. 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 .class file or loads modified bytecode into the JVM.
  • When processOrder() calls saveToDatabase(), the cross-cutting instructions are already compiled into the method body or call site.
  • Because AspectJ does not rely on outer proxy wrappers, internal this calls 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. Internal this calls 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 because this points 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 this resolves to the target instance).
  • ❌ “Annotating the class with @Transactional allows 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).