The Self-Invocation Problem and Proxy Bypass
The self-invocation problem is not a Spring bug. It is a direct consequence of how proxies work, and you saw exactly why in CH8. The proxy wraps the target object. External callers interact with the proxy. Internal calls from this go directly to the target. No proxy, no interceptor, no transaction.
This section demonstrates the problem in full, shows you how to diagnose it, and covers every known fix with their trade-offs.
Reproducing the Problem
The SaaS backend processes orders through OrderService. The method processOrder() persists the order and then validates the payment. The developer wants payment validation to run in an independent transaction so that a validation failure does not roll back the order itself:
// BROKEN: self-invocation bypasses the proxy
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final AuditRepository auditRepository;
@Transactional
public OrderResult processOrder(TenantId tenantId, OrderRequest request) {
Order order = Order.create(tenantId, request);
orderRepository.save(order);
try {
this.validatePayment(order, request.paymentDetails());
} catch (PaymentValidationException e) {
order.markPaymentFailed(e.getReason());
orderRepository.save(order);
}
return OrderResult.of(order);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void validatePayment(Order order, PaymentDetails payment) {
PaymentResult result = paymentGateway.authorize(payment);
if (!result.isAuthorized()) {
throw new PaymentValidationException(result.declineReason());
}
auditRepository.save(AuditEntry.paymentAuthorized(order, result));
}
}
The intent: if validatePayment() throws, its independent transaction rolls back the audit entry, and processOrder() catches the exception and marks the order as payment-failed in the outer transaction.
The reality: this.validatePayment() does not go through the proxy. REQUIRES_NEW never fires. Both operations run in a single transaction. If validatePayment() throws and processOrder() catches it, the audit entry is still in the same transaction. When processOrder() commits, the audit entry for a failed payment is committed alongside the order.
Detecting It
Enable transaction tracing:
logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
What you expect to see:
DEBUG JpaTransactionManager - Creating new transaction with name [OrderService.processOrder]: PROPAGATION_REQUIRED
DEBUG JpaTransactionManager - Suspending current transaction, creating new transaction with name [OrderService.validatePayment]: PROPAGATION_REQUIRES_NEW
DEBUG JpaTransactionManager - Initiating transaction commit [OrderService.validatePayment]
DEBUG JpaTransactionManager - Resuming suspended transaction after completion of inner transaction [OrderService.validatePayment]
DEBUG JpaTransactionManager - Initiating transaction commit [OrderService.processOrder]
What you actually see:
DEBUG JpaTransactionManager - Creating new transaction with name [OrderService.processOrder]: PROPAGATION_REQUIRED
DEBUG JpaTransactionManager - Initiating transaction commit [OrderService.processOrder]
One transaction. validatePayment does not appear in the logs. The TransactionInterceptor was never invoked for that method. That is your signal.
If you want programmatic detection during development, add a check:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void validatePayment(Order order, PaymentDetails payment) {
// Debug assertion: verify we are in a new transaction
assert !TransactionSynchronizationManager.isCurrentTransactionReadOnly();
boolean isNew = TransactionAspectSupport.currentTransactionStatus().isNewTransaction();
if (!isNew) {
log.error("validatePayment is not running in a new transaction. Self-invocation?");
}
// ...
}
Fix 1: Extract to a Separate Bean
The cleanest, most maintainable fix. Move the called method to its own bean:
// CORRECT: separate beans, each with its own proxy
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentValidationService paymentValidationService;
@Transactional
public OrderResult processOrder(TenantId tenantId, OrderRequest request) {
Order order = Order.create(tenantId, request);
orderRepository.save(order);
try {
paymentValidationService.validatePayment(order, request.paymentDetails());
} catch (PaymentValidationException e) {
order.markPaymentFailed(e.getReason());
orderRepository.save(order);
}
return OrderResult.of(order);
}
}
@Service
@RequiredArgsConstructor
public class PaymentValidationService {
private final PaymentGateway paymentGateway;
private final AuditRepository auditRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void validatePayment(Order order, PaymentDetails payment) {
PaymentResult result = paymentGateway.authorize(payment);
if (!result.isAuthorized()) {
throw new PaymentValidationException(result.declineReason());
}
auditRepository.save(AuditEntry.paymentAuthorized(order, result));
}
}
paymentValidationService is a proxy. The call goes through TransactionInterceptor. REQUIRES_NEW works. The logs now show two distinct transactions.
Trade-off: you add a class. In a well-structured codebase, this is not a cost. It is a natural separation of concerns. Payment validation is a distinct responsibility.
Fix 2: Inject Self via ObjectProvider
When extracting a bean is impractical (the method needs access to many private fields of the current class), inject the bean’s proxy into itself:
@Service
public class OrderService {
private final ObjectProvider<OrderService> self;
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final AuditRepository auditRepository;
public OrderService(ObjectProvider<OrderService> self,
OrderRepository orderRepository,
PaymentGateway paymentGateway,
AuditRepository auditRepository) {
this.self = self;
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
this.auditRepository = auditRepository;
}
@Transactional
public OrderResult processOrder(TenantId tenantId, OrderRequest request) {
Order order = Order.create(tenantId, request);
orderRepository.save(order);
try {
// Goes through the proxy
self.getObject().validatePayment(order, request.paymentDetails());
} catch (PaymentValidationException e) {
order.markPaymentFailed(e.getReason());
orderRepository.save(order);
}
return OrderResult.of(order);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void validatePayment(Order order, PaymentDetails payment) {
PaymentResult result = paymentGateway.authorize(payment);
if (!result.isAuthorized()) {
throw new PaymentValidationException(result.declineReason());
}
auditRepository.save(AuditEntry.paymentAuthorized(order, result));
}
}
ObjectProvider<OrderService> is a lazy lookup. self.getObject() returns the proxy from the BeanFactory. It does not cause a circular dependency because the resolution is lazy, not eager.
Trade-off: the code is less obvious. A reader must understand that self.getObject() returns the proxy, not a new instance. This is a pattern, not a convention. Document it with a comment when you use it.
Fix 3: ApplicationContext.getBean() (Discouraged)
The brute-force approach:
@Service
public class OrderService implements ApplicationContextAware {
private ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext ctx) {
this.applicationContext = ctx;
}
@Transactional
public OrderResult processOrder(TenantId tenantId, OrderRequest request) {
// ...
OrderService proxy = applicationContext.getBean(OrderService.class);
proxy.validatePayment(order, request.paymentDetails());
// ...
}
}
This works. The bean retrieved from ApplicationContext is the proxy. But it couples your service to the Spring container API. Your unit tests must now mock ApplicationContext. ObjectProvider is strictly better for this use case because it is injectable, testable, and typed.
The Pattern Extends Beyond @Transactional
This is not a @Transactional problem. It is a proxy problem. Every proxy-based feature in Spring has the same behavior:
@Cacheable:this.getCachedResult()bypasses the cache proxy. The method executes every time. No cache lookup, no cache put. (See CH8 for the proxy mechanism.)@Async:this.runInBackground()runs synchronously on the current thread. TheAsyncExecutionInterceptornever sees the call.@PreAuthorize:this.adminOperation()skips the security check. TheMethodSecurityInterceptoris not invoked. Any authenticated user can reach the method through this path.@Retryable(Spring Retry):this.callExternalApi()does not retry on failure. TheRetryOperationsInterceptoris bypassed.
The fix is always the same: ensure the call goes through the proxy. Extract to a separate bean or inject the proxy.
AspectJ Compile-Time Weaving: The Nuclear Option
AspectJ compile-time weaving (CTW) modifies the bytecode of your classes during compilation. It does not use proxies. The transactional behavior is woven directly into the class. this.validatePayment() works because the woven bytecode includes the transaction management logic in the method body itself.
Configuration:
@Configuration
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
public class TransactionConfig { }
You also need the AspectJ compiler plugin in your build:
<plugin>
<groupId>dev.aspectj</groupId>
<artifactId>aspectj-maven-plugin</artifactId>
<version>1.14.1</version>
<configuration>
<complianceLevel>21</complianceLevel>
<aspectLibraries>
<aspectLibrary>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</aspectLibrary>
</aspectLibraries>
</configuration>
</plugin>
Why most teams should not use it:
- Build complexity: AspectJ requires a separate compiler. IDE support is inconsistent. IntelliJ handles it; VS Code and Eclipse need plugins that frequently break.
- Debugging difficulty: Stack traces show woven code that does not match your source. Breakpoints land in unexpected places.
- Team knowledge: Every developer must understand compile-time weaving. In a team of 15, this is a training burden that rarely justifies the benefit.
- Partial escape: You solve self-invocation but introduce a different category of problems. Weaving order, aspect precedence, and load-time weaving vs. compile-time weaving become new sources of confusion.
The proxy-based model with explicit bean extraction is simpler to understand, debug, and maintain. Reserve AspectJ CTW for cases where you have hundreds of self-invocations that cannot be refactored, and even then, consider whether the architecture needs rethinking.
The Rule
If a method annotated with @Transactional, @Cacheable, @Async, or any proxy-based annotation is called from within the same class, the annotation has no effect. The call bypasses the proxy. The interceptor never runs. Your configuration is silently ignored.
The fix is structural: separate the caller and the callee into different beans. Each bean gets its own proxy. Each proxy runs the interceptors. The system works as annotated.