A Spring Boot 4.1.1 module whose test suite is the evidence for the article: 19 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. - 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.
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 orprivatemethod is silently ignored. Two calls, two repository hits, no warning. @PostConstructruns 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..