skip to content

A teammate reports that @Cacheable on a service method does nothing. Walk through how you'd diagnose whether a method-visibility/proxy limitation is the cause.

level: seniorimportance: should knowfreq 50%

answer

  1. 3 causes: modifier, self-invocation, not-a-proxy
  2. Check getClass() for $$SpringCGLIB$$ / $Proxy
  3. AopUtils.isAopProxy / isCglibProxy
  4. Kotlin → needs 'open' / all-open plugin
  5. Fix: separate bean, self-ref proxy, or AspectJ

basics

~20 s

Check whether the method is public and non-final, whether it's called from inside the same bean (self-invocation bypasses the proxy), and whether the bean is actually a proxy. Private/final/static methods or internal calls silence @Cacheable with no error.

solid answer

~50 s

I'd check three proxy-related causes in order. First, method eligibility: is the method public/protected and non-final, non-static? @Cacheable on a private, final, or static method is silently ignored because the proxy can't override it. Second, self-invocation: if another method in the same bean calls the cached method via 'this', the call never leaves the object, so the proxy isn't involved and caching is skipped — even if the method signature is fine. Third, is the bean actually proxied? Inject it and check the class name (should contain $$SpringCGLIB$$ or be a $Proxy). I'd also confirm @EnableCaching is present and a CacheManager exists. For Kotlin, verify the class/method is 'open'. If the method genuinely must be private/final/static or self-invoked, the fix is to move it to another bean, refactor the call to go through the proxy, or switch to AspectJ.

code

java · 21 lines
java
@Service
public class ReportService {

    // The cached method is PUBLIC & non-final -> eligible for proxying
    @Cacheable("reports")
    public Report load(long id) { return expensiveBuild(id); }

    // BUG: internal call via 'this' bypasses the proxy -> cache never used
    public Report loadWithHeader(long id) {
        Report r = this.load(id);   // self-invocation: @Cacheable skipped
        return r.withHeader();
    }
}

// Quick diagnostic in a test / CommandLineRunner:
// System.out.println(reportService.getClass().getName());
//   -> ...ReportService$$SpringCGLIB$$0  (proxied)   vs   plain ReportService (not proxied)
// org.springframework.aop.support.AopUtils.isCglibProxy(reportService);

// Fix option: push the cached method into its own bean and inject it,
// so loadWithHeader() calls it through that bean's proxy.

go deeper

for a junior

Likely only spots the modifier issue; may not know about self-invocation.

for a middle

Should check modifiers and know self-invocation bypasses the proxy.

for a senior

Should run a structured diagnosis (modifier vs self-invocation vs not-a-proxy vs config) and verify with AopUtils / class-name inspection, then pick a targeted fix.

for a principal

Should also address prevention: tests that exercise beans through injected refs, lint/convention to catch @Cacheable on non-public methods, and when to standardize on AspectJ.

## Framing the diagnosis `@Cacheable` (like `@Transactional`, `@Async`, `@Retryable`) is applied by a **proxy** wrapped around the bean. If caching "does nothing", the advice interceptor is not running. There are three proxy-specific failure modes tied to this leaf, plus config sanity checks. ### 1. Method not interceptable (visibility/modifier) The proxy can only advise **public/protected, non-final, non-static instance methods** (JDK proxies: public interface methods only). Verify the exact signature: - `private` → not overridable, not visible → silently ignored. - `final` → JVM forbids override → silently ignored. - `static` → not polymorphic → never advised. - **Kotlin**: everything is `final`/`private`-tight by default; without the `kotlin-spring` (all-open) plugin, even a `public` method is effectively final and won't be advised. ### 2. Self-invocation Even a perfectly public non-final method won't be advised if it's called **from within the same bean** via `this.method()` (or an implicit unqualified call). The internal call goes straight to the real object and never passes through the proxy. This is the single most common cause and is easy to miss because the method "looks" fine. Distinct mechanism from the modifier limits, but the symptom (silent no-op) is identical. ### 3. Is it even a proxy? Confirm the bean is proxied at all: - Log/inspect `bean.getClass().getName()` — expect `...$$SpringCGLIB$$...` or `com.sun.proxy.$ProxyN`. - Use `AopUtils.isAopProxy(bean)`, `isCglibProxy`, `isJdkDynamicProxy`. - Enable startup logging; Spring sometimes logs when a method can't be advised. ### 4. Configuration sanity - `@EnableCaching` present on a `@Configuration` class. - A `CacheManager` bean exists. - The cache annotation is on the invoked method, not an overload/bridge. ## Deciding the fix - **Modifier problem** → change to public/protected non-final (or `open` in Kotlin), or apply the all-open plugin. - **Self-invocation** → move the cached method into a **separate bean** and inject it; or inject a self-reference (e.g. `@Lazy` self, or `AopContext.currentProxy()` with `exposeProxy=true`) and call through the proxy; or restructure so the call is external. - **Fundamentally needs private/final/static or same-object call** → use **AspectJ** load-time/compile-time weaving, which weaves into the bytecode and has none of these limitations. ## Verification Add a test that calls the method twice **through the injected proxy** and asserts the underlying work ran once (e.g. verify a mock repository invoked once, or assert the `CacheManager`'s cache is populated). A test that calls the method internally would reproduce the self-invocation bug and mislead you — always exercise the bean via its injected reference.

  • How would you confirm at runtime that a specific bean is actually wrapped in an AOP proxy?
    Inspect bean.getClass().getName() for a $$SpringCGLIB$$ or $Proxy marker, or use AopUtils.isAopProxy/isCglibProxy/isJdkDynamicProxy. You can also check AopProxyUtils/getTargetClass to see the real underlying type.
  • The method must be called from within the same class. How do you still get the caching to apply?
    Route the call through the proxy: extract the method into a separate injected bean, or inject a self-reference (a @Lazy self bean, or AopContext.currentProxy() with exposeProxy=true) and call that. Otherwise switch to AspectJ weaving, which doesn't use proxies.

context