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
Lesson 12 of 14
1d streakThe 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 Technology | Mechanism | Requirements / Trade-Offs | Spring Boot 3 Default |
|---|---|---|---|
| JDK Dynamic Proxy | java.lang.reflect.Proxy generates a dynamic class implementing interfaces at runtime | Target class MUST implement at least one interface. Cannot proxy concrete classes. | Used only when explicitly configured |
| CGLIB Subclass Proxy | Enhancer generates a bytecode subclass of the target class at runtime | Classes 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:
@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
- 01Solution 1 (Recommended: Extract Collaborator): Move saveAuditLog() into a separate AuditService. Calling auditService.saveAuditLog() enters through the AuditService proxy cleanly.
- 02Solution 2 (Self-Injection with @Lazy): Inject private final ObjectProvider<OrderService> selfProvider into OrderService and call selfProvider.getObject().saveAuditLog().
- 03Solution 3 (AopContext.currentProxy()): Enable @EnableAspectJAutoProxy(exposeProxy = true) and call ((OrderService) AopContext.currentProxy()).saveAuditLog(). Highly discouraged as it couples code to Spring AOP.
// 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.
// 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
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.
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);
}
}
}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
Key Takeaway
Spring declarative advice requires method invocations to cross the dynamic proxy boundary. Calls on 'this' bypass proxies, silently disabling transactions, security, and caching.
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
Lesson Primary References
Supplementary Module Resources