Intermediate · Accelerate

Understand What Spring Cache Does—and Does Not Do

Trace annotation interception, key resolution, provider calls, and proxy limitations.

Primary source: Spring Boot 3.5 Reference: Caching

35 min

Lesson 1 of 4

1d streak

The Architectural Question

@Cacheable looks declarative until a self-invocation skips the proxy and the "cached" method loads on every single request.

What this guide proves

  • Choose an explicit cache boundary
  • Use stable collision-resistant keys
  • Remember that self-invocation may bypass caching proxies

The Spring Cache Abstraction and Proxy Boundary

The Spring Cache abstraction is not an in-memory cache implementation; it is a declarative, AOP-driven facade over third-party cache providers. When you annotate a method with @Cacheable, Spring dynamically wraps the enclosing bean in a CGLIB or JDK dynamic proxy that intercepts method execution.

Before executing the method body, the caching aspect computes a cache key, queries the configured org.springframework.cache.CacheManager, and returns the cached value immediately on a hit. The actual method body executes only when a cache miss occurs, at which point the returned value is automatically placed into the cache before returning to the caller.

src/main/java/com/example/catalog/ProductCatalogService.java
package com.example.catalog;

import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class ProductCatalogService {

    private final ProductRepository repository;

    public ProductCatalogService(ProductRepository repository) {
        this.repository = repository;
    }

    // Intercepted by CGLIB proxy: if key exists in "products", method body is skipped
    @Cacheable(cacheNames = "products", key = "#sku", unless = "#result == null")
    public Optional<Product> findBySku(String sku) {
        // Only executes on cache miss
        return repository.findBySku(sku);
    }

    // Always executes the method body, then updates the cache entry with the result
    @CachePut(cacheNames = "products", key = "#product.sku()")
    public Product updateProduct(Product product) {
        return repository.save(product);
    }

    // Evicts the entry from "products" when product is deleted or archived
    @CacheEvict(cacheNames = "products", key = "#sku")
    public void deleteProduct(String sku) {
        repository.deleteBySku(sku);
    }
}

Difference Between @Cacheable and @CachePut

@Cacheable checks the cache first and short-circuits execution on a hit. @CachePut always executes the underlying method body and unconditionally updates the cache entry with the return value. Use @CachePut for write/update operations, never for read-only lookups.

SpEL Key Resolution, Collision Defense, and Namespaces

By default, Spring utilizes SimpleKeyGenerator to generate keys. If a method takes zero parameters, it returns SimpleKey.EMPTY. If it takes one parameter, it returns that parameter directly. If it takes multiple parameters, it wraps them into a composite SimpleKey object.

In production environments, relying on default key generation across multiple entities is dangerous. A User entity with ID 42 and a Product entity with ID 42 will collide if both use default keys in un-namespaced caches. Always specify explicit SpEL keys or customized KeyGenerator beans.

SpEL VariableDescriptionExample Expression
#root.methodNameName of the intercepted methodkey = "#root.methodName + ':' + #id"
#root.args[n]Argument by positional indexkey = "#root.args[0]"
#paramNameMethod parameter by name (requires -parameters flag)key = "'sku:' + #sku.toLowerCase()"
#resultThe return value of the method (only in @CachePut/unless)unless = "#result == null || #result.isDraft()"
#root.caches[0].nameThe target cache container namekey = "#root.caches[0].name + ':' + #id"
src/main/java/com/example/catalog/PrefixedKeyGenerator.java
package com.example.catalog;

import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Arrays;

@Component("prefixedKeyGenerator")
public class PrefixedKeyGenerator implements KeyGenerator {

    @Override
    public Object generate(Object target, Method method, Object... params) {
        // Enforce safe namespace prefixing: TargetClass:MethodName:arg1:arg2
        return target.getClass().getSimpleName() + ":" + method.getName() + ":" + Arrays.deepToString(params);
    }
}

Compile with the -parameters Compiler Flag

Referencing method arguments by parameter name (e.g. key = "#sku") requires compilation with the standard javac -parameters flag. If omitted, parameter names are erased from bytecode, causing SpEL evaluation exceptions at runtime.

The Self-Invocation Bypass Trap and Proxy Resolution

Because Spring caching relies on AOP proxies by default (mode = AdviceMode.PROXY), calling a @Cacheable method from another method inside the very same class completely bypasses the caching proxy. The invocation is dispatched directly on the internal this reference, skipping the cache interception aspect entirely.

The Silent Self-Invocation Failure

Calling this.findBySku(sku) inside searchProducts() in the same bean will never consult the cache. Every search request will execute the database query directly, causing database saturation under load without throwing any error.

To resolve self-invocation without breaking architectural layering, extract the cached operation into a dedicated collaborator bean, or inject a self-reference via setter/provider:

src/main/java/com/example/catalog/CatalogLookupService.java
package com.example.catalog;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import java.util.Optional;

@Service
public class CatalogLookupService {

    private CatalogLookupService self;

    // Self-injection via @Lazy setter to route internal calls through the CGLIB proxy
    @Autowired
    public void setSelf(@Lazy CatalogLookupService self) {
        this.self = self;
    }

    public Optional<Product> getOrHydrate(String sku) {
        // Calling self.findBySku routes through proxy and hits cache!
        return self.findBySku(sku);
    }

    @Cacheable(cacheNames = "products", key = "#sku")
    public Optional<Product> findBySku(String sku) {
        return Optional.of(new Product(sku, "Widget", 1999));
    }
}

Validation, Hit-Rate Metrics, and Verification Checkpoints

Verify caching behavior by measuring hit rates and invocation counters using Spring Boot Actuator metrics before promoting to production:

verify-cache-behavior.sh
# 1. First request triggers cache miss and populates cache
curl -i http://localhost:8080/api/products/SKU-42
# HTTP/1.1 200 OK -> Database log: SELECT * FROM products WHERE sku = 'SKU-42'

# 2. Second request hits the cache instantly (no DB query executed)
curl -i http://localhost:8080/api/products/SKU-42

# 3. Query cache metrics via Actuator
curl -s http://localhost:8080/actuator/metrics/cache.gets?tag=cache:products
# Verify {"name":"cache.gets","measurements":[{"statistic":"COUNT","value":2.0}]}

Active Recall Checkpoint

Why does calling this.find(id) fail to hit the cache even if find() is annotated with @Cacheable?

Hint: Consider how Spring AOP wraps beans in CGLIB subclasses.

Show answer & explanation

Spring Cache uses dynamic proxies. Calling a method on "this" invokes the target object directly without passing through the interceptor proxy.

Runtime Architecture & Flow

Persistence context · runtime visualization

Understand What Spring Cache Does—and Does Not Do

Transaction

Choose an explicit cache boundary

Entity state

Use stable collision-resistant keys

Flush

Remember that self-invocation may bypass caching proxies

Commit

Cache a product-view method and verify hit and miss behavior.

Working Implementation

Code Walkthrough

Cache a product-view method and verify hit and miss behavior.

CacheAbstractionLab.java
package com.example.cache;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

@Service
public class CacheAbstractionLab {

    private final Map<String, Product> database = new ConcurrentHashMap<>();
    private final AtomicInteger databaseQueries = new AtomicInteger();
    private CacheAbstractionLab self;

    // Self-injection via @Lazy setter to route internal calls through the CGLIB proxy
    @Autowired
    public void setSelf(@Lazy CacheAbstractionLab self) {
        this.self = self;
    }

    public record Product(String sku, String name, int priceCents) {}

    // First call executes method body; subsequent calls return cached value
    @Cacheable(cacheNames = "catalog", key = "#sku", unless = "#result == null")
    public Optional<Product> findBySku(String sku) {
        databaseQueries.incrementAndGet();
        return Optional.ofNullable(database.get(sku));
    }

    // Calling self.findBySku routes through proxy and hits cache; this.findBySku would bypass proxy!
    public Optional<Product> getThroughProxy(String sku) {
        return self.findBySku(sku);
    }

    @CacheEvict(cacheNames = "catalog", key = "#sku")
    public void evict(String sku) {
        database.remove(sku);
    }

    public void seed(Product product) {
        database.put(product.sku(), product);
    }

    public int getDatabaseQueryCount() {
        return databaseQueries.get();
    }
}
@Cacheable intercepts method invocation; if the key exists in the cache, the method body is skipped.
Calling this.findBySku() internally bypasses the CGLIB proxy; self-injection via @Lazy ensures internal calls consult the cache.
@CacheEvict clears the designated key so subsequent reads re-populate from the database.

Synthesis & Production Verification

Production Acceptance Criteria

  • Trace one call from interception through key generation to the provider
  • Design keys that are stable, collision-resistant, and namespace-safe
  • Demonstrate the self-invocation bypass and its repair

Architectural & Technical Review

Core Interview Questions

Q01How does Spring Cache intercept method execution and under what conditions does it skip the method body?Show answer

Spring wraps the bean in a CGLIB or JDK dynamic proxy. When a caller invokes a @Cacheable method, the proxy intercepts the call, computes the key, and queries the CacheManager. If a cached value is present, the proxy returns it immediately and skips the method body.

Q02Why is relying on default SimpleKeyGenerator dangerous in multi-entity production systems?Show answer

SimpleKeyGenerator computes keys solely based on method arguments without entity namespaces. If User and Product services both look up ID 42, their keys collide in shared caches. Explicit SpEL keys or namespaced KeyGenerators are required.

Q03Why does calling this.findProduct(sku) bypass @Cacheable, and how do you resolve it without breaking architectural layering?Show answer

Direct calls via "this" invoke the target object directly without passing through the CGLIB proxy interceptor. This can be resolved by extracting the cached method into a collaborator bean or by injecting a self-reference via a @Lazy setter.

Production Pitfalls

Critical Anti-Patterns

  • Pitfall: Invoking cached methods on this reference inside the same bean — Bypasses the CGLIB proxy entirely, executing the underlying database query on every request without caching.
  • Pitfall: Omitting -parameters compiler flag when using SpEL argument keys — Causes parameter names to be erased from bytecode, resulting in SpEL evaluation exceptions at runtime.

Primary Documentation & Specifications

Supplementary Module Resources