Foundation · Understand

Proxy Boundaries and Self-Invocation

Explain why internal calls bypass transactional, async, security, and caching advice, then structure services so proxy boundaries are crossed intentionally.

Primary source: Spring Framework 6.2 Reference: Understanding AOP Proxies

40 min

Lesson 12 of 14

1d streak

The Architectural Question

Transactional, @Async, @Cacheable, and @PreAuthorize behavior lives in proxies; a this-call never touches the proxy, so the advice silently never runs.

What this guide proves

  • Know which annotations depend on Spring proxies
  • Prove self-invocation bypasses advice
  • Move advised behavior behind another bean or explicit boundary

JDK dynamic proxies vs CGLIB: How Spring creates interception layers

Declarative Spring annotations like @Transactional, @Cacheable, @Async, and @PreAuthorize do not modify your compiled Java class. Instead, Spring wraps your bean in a Dynamic Proxy that intercepts calls, executes cross-cutting advice, and delegates to the real target object.

Proxy TechnologyMechanismRequirements / Trade-OffsSpring Boot 3 Default
JDK Dynamic Proxyjava.lang.reflect.Proxy generates a dynamic class implementing interfaces at runtimeTarget class MUST implement at least one interface. Cannot proxy concrete classes.Used only when explicitly configured
CGLIB Subclass ProxyEnhancer generates a bytecode subclass of the target class at runtimeClasses and methods must NOT be final (cannot subclass final classes or override final methods).UNIVERSAL DEFAULT (spring.aop.proxy-target-class=true)

The self-invocation trap: Why this.method() bypasses proxies

The most notorious architectural trap in Spring occurs when a method in a service calls another advised method in the same class using this:

OrderService.java
@Service
public class OrderService {

    public void processOrder(Long id) {
        // Internal call: invokes this.saveAuditLog() directly!
        saveAuditLog(id); 
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveAuditLog(Long id) {
        // Intended to run in a separate autonomous transaction!
    }
}

When an external controller calls orderService.processOrder(), the call enters through the CGLIB proxy. But when processOrder() calls saveAuditLog(), the call executes directly on this (the raw target instance). The call never crosses the proxy boundary, so transaction interceptors are completely bypassed!

Three production solutions for self-invocation

  1. 01Solution 1 (Recommended: Extract Collaborator): Move saveAuditLog() into a separate AuditService. Calling auditService.saveAuditLog() enters through the AuditService proxy cleanly.
  2. 02Solution 2 (Self-Injection with @Lazy): Inject private final ObjectProvider<OrderService> selfProvider into OrderService and call selfProvider.getObject().saveAuditLog().
  3. 03Solution 3 (AopContext.currentProxy()): Enable @EnableAspectJAutoProxy(exposeProxy = true) and call ((OrderService) AopContext.currentProxy()).saveAuditLog(). Highly discouraged as it couples code to Spring AOP.
ArchitecturalResolution.java
// SOLUTION 1: Clean Architectural Separation
@Service
public class OrderService {
    private final AuditService auditService; // Separate collaborator!

    public OrderService(AuditService auditService) {
        this.auditService = auditService;
    }

    public void processOrder(Long id) {
        auditService.saveAuditLog(id); // Enters through AuditService proxy cleanly!
    }
}

Break it deliberately: Silent transaction bypass on self-invocation

A self-invoked @Transactional method will silently fail to commit or rollback independently because no proxy interceptor ever opened a database transaction.

Terminal / Diagnostic Output
// Database transaction trace:
DEBUG o.s.orm.jpa.JpaTransactionManager: Participating in existing transaction (or NO transaction started)!
ERROR com.example.OrderService: Database constraint failed!
WARN: Expected REQUIRES_NEW rollback, but outer transaction committed corrupted state because proxy was bypassed!

The Golden Rule of Proxies

Spring AOP advice only runs when a method call enters from an external caller across the proxy boundary. Calls on "this" will NEVER trigger @Transactional, @Cacheable, @Async, or @PreAuthorize.

Check your understanding

Active Recall Checkpoint

Why does invoking this.helperMethod() inside a service fail to trigger @Transactional on helperMethod()?

Hint: Remember which object holds the transaction interceptor.

Show answer & explanation

The transaction interceptor lives inside the external dynamic proxy wrapping the service. Calling this.helperMethod() executes within the raw target object, bypassing the proxy and its interceptor chain entirely.

Active Recall Checkpoint

Why does Spring Boot 3 default to CGLIB proxies rather than JDK dynamic proxies?

Hint: Consider interface requirements and class injection.

Show answer & explanation

CGLIB creates class subclasses rather than requiring interfaces, allowing Spring Boot to inject concrete classes directly into @Autowired points without ClassCastException when classes lack interfaces.

Runtime Architecture & Flow

Application startup · runtime visualization

Proxy Boundaries and Self-Invocation

Environment

Know which annotations depend on Spring proxies

Conditions

Prove self-invocation bypasses advice

Bean graph

Move advised behavior behind another bean or explicit boundary

Runtime

Call a transactional method from another method on the same class, observe missing advice, then move the advised use case to a collaborator.

Working Implementation

Code Walkthrough

Call a transactional method from another method on the same class, observe missing advice, then move the advised use case to a collaborator.

SelfInvocationBypassLab.java
package com.example.core;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

public class SelfInvocationBypassLab {
    // BROKEN PATTERN: Internal self-invocation bypasses proxy
    @Service
    public static class FlawedOrderService {
        public void placeOrder(String orderId) {
            // Calling this.recordAudit() directly bypasses the CGLIB proxy; @Transactional is IGNORED!
            this.recordAudit(orderId);
        }

        @Transactional(propagation = Propagation.REQUIRES_NEW)
        public void recordAudit(String orderId) {
            System.out.println("Recording audit directly without proxy advice: " + orderId);
        }
    }

    // PRODUCTION REFACTOR: Extract collaborator to cross the proxy boundary
    @Service
    public static class AuditService {
        @Transactional(propagation = Propagation.REQUIRES_NEW)
        public void recordAudit(String orderId) {
            System.out.println("Proxy traversed! Active REQUIRES_NEW transaction verified for: " + orderId);
        }
    }

    @Service
    public static class CompliantOrderService {
        private final AuditService auditService;
        public CompliantOrderService(AuditService auditService) {
            this.auditService = auditService;
        }

        public void placeOrder(String orderId) {
            // Crosses the CGLIB proxy boundary: @Transactional advice intercepts the call
            auditService.recordAudit(orderId);
        }
    }
}
Spring AOP generates dynamic proxies that intercept incoming external calls to add transactional, caching, or security behavior.
Calling this.method() stays inside the target instance and never traverses the proxy, silently skipping the advice.
Moving recordAudit to an AuditService collaborator makes the call cross a managed proxy and starts REQUIRES_NEW.
The same limitation applies to proxy-based caching (@Cacheable), async execution (@Async), method security (@PreAuthorize), and retry advice (@Retryable).

Synthesis & Production Verification

Production Acceptance Criteria

  • List four annotations whose advice depends on Spring proxies
  • Demonstrate a self-invocation skipping its advice
  • Fix it by extracting an advised collaborator bean

Architectural & Technical Review

Core Interview Questions

Q01Why does calling this.transactionalMethod() from within the same class fail to start a database transaction?Show answer

Transaction interceptors live inside the external dynamic proxy wrapping the bean. Calling this.method() executes directly within the raw target object, bypassing the proxy and its advice chain entirely.

Q02Why does Spring Boot 3 default to CGLIB class proxies (proxy-target-class=true) rather than JDK dynamic proxies?Show answer

CGLIB subclasses target classes rather than requiring interfaces, allowing Spring Boot to inject concrete classes directly into @Autowired injection points without ClassCastException.

Q03What are the three production solutions for resolving the proxy self-invocation bypass, and which is recommended?Show answer

Solutions include: (1) Extracting the transactional method into a separate collaborator (Recommended), (2) Self-injection via ObjectProvider or @Lazy, and (3) AopContext.currentProxy(). Extracting a collaborator is preferred as it preserves clean service boundaries.

Production Pitfalls

Critical Anti-Patterns

  • Pitfall: Assuming internal method calls participate in @Transactional — Calling an internal method annotated with @Transactional(propagation = Propagation.REQUIRES_NEW) from within the same class runs in the outer transaction without isolation because the proxy is bypassed.
  • Pitfall: Coupling business services to AopContext.currentProxy() — Reaching into AopContext couples domain code directly to Spring AOP internals and fails if exposeProxy is not explicitly enabled.

Primary Documentation & Specifications

Supplementary Module Resources