Databases, Spring and microservices · 2. Spring Core and Spring MVC, lesson 6 of 8

How Spring proxies work: JDK dynamic proxies, CGLIB and the self-invocation trap

Advanced3 min read@since 17Code runs on your Java 25
Explain it forThe essentials plus production detail and pitfalls.

@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:

  1. Self-invocation: this.otherMethod() calls the real object directly, skipping the proxy, so annotations on otherMethod are ignored.
  2. final and private methods can't be overridden by CGLIB, so they can't be intercepted. Keep annotated methods public and non-final.
  3. Construction time: inside a constructor or @PostConstruct, calls on this aren'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).

Proxy lab

Example

Java
@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

Where this leads

You've reached the end of this thread. Try a learning path for what's next.

Part of Spring Core and Spring Security in depth.

Was this lesson helpful?

Finished reading? Mark it complete to track your progress.