Files
spring-boot-demo/caching/docs/03-self-invocation.md
T
asmhatre a9867c0423 Add caching: the Spring cache abstraction, keys, eviction timing and the self-invocation trap
A Spring Boot 4.1.1 module whose test suite is the evidence for the article: 20 tests
producing 22 transcripts under docs/output/, plus 12 documentation chapters.

Findings the build pins:
- @EnableCaching has no exposeProxy attribute; the widely-copied
  @EnableCaching(exposeProxy = true) does not compile.
- Two methods sharing a cache name and an argument type share a key space, and one
  silently serves the other's answers.
- The documented cache-provider detection order does not match CacheType's enum
  order in 4.1.1: COUCHBASE before INFINISPAN, and CACHE2K before CAFFEINE.
- beforeInvocation = true is NOT deferred by TransactionAwareCacheManagerProxy on
  7.0.9 - doEvict picks evictIfPresent, which the decorator does not intercept.
- Four of five invalid declarations start a clean context and throw at the first call.
- Caffeine on the classpath silently displaces the simple provider.
2026-09-12 05:35:13 +00:00

3.8 KiB

← the three annotations · next: keys →

3. The self-invocation trap

The symptom: @Cacheable is on the method, the application starts cleanly, nothing is logged, and the cache is empty. Or worse, the cache works when the method is called from a controller and does not when it is called from a sibling method three lines away.

The mechanism

@EnableCaching registers an auto-proxy creator. The bean the container hands out is a proxy — in this module a CGLIB subclass, printed in docs/output/03-self-invocation.txt:

injected bean class : com.ankurm.caching.selfinvocation.CatalogService$$SpringCGLIB$$0
is an AOP proxy?    : true
target class        : com.ankurm.caching.selfinvocation.CatalogService

The interceptor lives in the proxy. this.lookup(...) inside the target object is a plain virtual call on this, which is the target, not the proxy. The interceptor is never reached.

Measured over four ISBNs with two repeats, so a working cache does two lookups:

this.lookup(..)                 -> 4 repository calls   <- no caching at all
self.getObject().lookup(..)     -> 2 repository calls
AopContext.currentProxy()       -> 2 repository calls
a second bean calls lookup(..)  -> 2 repository calls

The three fixes, ranked

1. Move the call to another bean. The loop and the cached lookup belong to different responsibilities anyway. No Spring-specific machinery, no cycle, testable in isolation. This is the one to reach for.

2. Inject yourself as an ObjectProvider.

private final ObjectProvider<CatalogService> self;
...
CatalogService proxy = self.getObject();

ObjectProvider defers the lookup, so there is no constructor cycle. @Lazy CatalogService self works the same way. It is honest about what it is doing, which is more than can be said for the next option.

3. AopContext.currentProxy(). Works, but only when the proxy was created with exposeProxy on — and, as chapter 2 notes, @EnableCaching has no such attribute. @EnableAspectJAutoProxy(exposeProxy = true) is the usual advice and drags in AspectJ. This module flips the flag on the creator @EnableCaching already registered:

@Bean
static BeanFactoryPostProcessor exposeCachingProxy() {
    return beanFactory -> {
        if (beanFactory instanceof BeanDefinitionRegistry registry) {
            AopConfigUtils.registerAutoProxyCreatorIfNecessary(registry);
            AopConfigUtils.forceAutoProxyCreatorToExposeProxy(registry);
        }
    };
}

The cost is a ThreadLocal read and a cast in business code, and it only works inside a call that was itself intercepted.

The fourth option: stop using proxies

@EnableCaching(mode = AdviceMode.ASPECTJ) with compile-time or load-time weaving modifies the bytecode, so self-invocation and non-public methods are both intercepted. It is a real answer and almost nobody takes it, because the build complexity is not worth it for caching alone.

Two relatives of the same bug

docs/output/04-non-public-and-postconstruct.txt:

  • A non-public annotated method is never advised. In proxy mode the annotation on a protected, package-private or private method is silently ignored. Two calls, two repository hits, no warning.
  • @PostConstruct runs before the proxy is in place. A warm-up loop in an init method warms nothing. The reference documentation says so, and it still catches people.

How to tell in ten seconds

Inject the bean, print AopUtils.isAopProxy(bean) and bean.getClass().getName(). If the class name has no $$SpringCGLIB$$ or $Proxy in it, there is no interceptor and nothing downstream matters. If it does, but the cache is still empty, the call is not going through it — look for this..