How Spring proxies work: JDK dynamic proxies, CGLIB and the self-invocation trap
@Transactional, @Cacheable, @Async, @PreAuthorize, @Retryable and your own aspects all work the same way: Spring injects a proxy instead of your object. The proxy runs extra logic (start a transaction, check the cache, check permissions), then calls your method.
- JDK dynamic proxy: implements your bean's interfaces. It can only be injected by an interface type.
- CGLIB proxy: a generated subclass that overrides your public methods. It works without interfaces, and Spring Boot uses it by default.
Because it's a proxy, some things can't work:
- Self-invocation:
this.otherMethod()calls the real object directly, skipping the proxy, so annotations onotherMethodare ignored. - final and private methods can't be overridden by CGLIB, so they can't be intercepted. Keep annotated methods public and non-final.
- Construction time: inside a constructor or
@PostConstruct, calls onthisaren't proxied either.
Fixes for self-invocation: move the method into another bean (best), use TransactionTemplate for programmatic transactions, or inject the bean into itself lazily (works, but is a smell).
Example
@Service
public class ReportService {
@Transactional(readOnly = true)
public Report build(long id) { /* several queries that should share one transaction */ }
public List<Report> buildAll(List<Long> ids) {
return ids.stream().map(this::build).toList(); // this.build(): NO transaction, the proxy is bypassed
}
}
System.out.println(reportService.getClass().getSimpleName()); // ReportService$$SpringCGLIB$$0
// Fix: a programmatic transaction around the work
@Service
public class ReportService {
private final TransactionTemplate tx;
ReportService(PlatformTransactionManager transactionManager) {
this.tx = new TransactionTemplate(transactionManager);
}
public List<Report> buildAll(List<Long> ids) {
return tx.execute(status -> ids.stream().map(this::load).toList());
}
}Common mistake
Annotating a private, final or internally called method with @Transactional (or @Async, @Cacheable) and assuming it works. It silently doesn't.
Under the hood
CGLIB creates the proxy instance without calling your constructor, so the proxy's own fields are null; it holds a reference to the real target bean and delegates every intercepted call to it. Spring AOP is proxy-based and only intercepts method calls on beans. Full AspectJ weaving (at compile or load time) can intercept self-calls, constructors and field access, at the cost of a more complex build.
Check yourself
Which proxy type does Spring Boot use by default?
How this connects
Know these first
Where this leads
You've reached the end of this thread. Try a learning path for what's next.
Was this lesson helpful?
Finished reading? Mark it complete to track your progress.