Fix: Spring Boot @Cacheable Not Working — Cache Miss Every Time or Stale Data
Quick Answer
How to fix Spring Boot @Cacheable issues — @EnableCaching missing, self-invocation bypass, key generation, TTL configuration, cache eviction, and Caffeine vs Redis setup.
The Problem
@Cacheable doesn’t cache — the method is called on every request:
@Service
public class UserService {
@Cacheable("users")
public User getUserById(Long id) {
System.out.println("Fetching from database..."); // Prints every time
return userRepository.findById(id).orElseThrow();
}
}Or a call within the same class bypasses the cache:
@Service
public class OrderService {
@Cacheable("orders")
public List<Order> getOrdersByUser(Long userId) { ... }
public OrderSummary getSummary(Long userId) {
// This call skips the cache — self-invocation problem
List<Order> orders = getOrdersByUser(userId);
return buildSummary(orders);
}
}Or the cache stores stale data and @CacheEvict doesn’t clear it:
@CacheEvict(value = "users", key = "#user.id")
public void updateUser(User user) { ... }
// Cache still returns old data after updateWhy This Happens
Spring’s caching abstraction uses AOP proxies. Like @Transactional, this means the cache only works on calls that go through the Spring proxy:
@EnableCachingmissing — the caching infrastructure doesn’t activate without this annotation on a configuration class. Without it,@Cacheableis silently ignored.- Self-invocation bypasses the proxy — when a method in the same class calls another cached method directly, it calls the raw object, not the proxy. The cache is never consulted.
- No
CacheManagerbean — Spring needs aCacheManagerto know where to store cached values. Without explicit configuration, Spring auto-configures one only if a supported cache library (Caffeine, Redis, EhCache) is on the classpath. - Wrong key —
@Cacheable("users")without akeyattribute uses all method parameters as the key. If the method has no parameters, all calls share one cache entry. @CacheEvictkey mismatch — if the key used in@Cacheableand@CacheEvictdon’t match, eviction clears a different entry than the one being read.
Fix 1: Add @EnableCaching
Add @EnableCaching to your main application class or any @Configuration class:
// SpringBootApplication already includes @SpringBootConfiguration
// but @EnableCaching must be added explicitly
@SpringBootApplication
@EnableCaching
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
// Or in a separate configuration class
@Configuration
@EnableCaching
public class CacheConfig {
// Cache manager bean defined here
}Fix 2: Fix Self-Invocation
Calls from within the same class bypass the Spring proxy. Use one of these approaches:
// Option 1 — Inject the service into itself (simplest fix)
@Service
public class OrderService {
@Autowired
private OrderService self; // Spring injects the proxy
@Cacheable("orders")
public List<Order> getOrdersByUser(Long userId) { ... }
public OrderSummary getSummary(Long userId) {
// Call through the proxy — cache works
List<Order> orders = self.getOrdersByUser(userId);
return buildSummary(orders);
}
}// Option 2 — Extract cached methods to a separate class
@Service
public class OrderCacheService {
@Cacheable("orders")
public List<Order> getOrdersByUser(Long userId) {
return orderRepository.findByUserId(userId);
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderCacheService orderCacheService;
public OrderSummary getSummary(Long userId) {
// Call through proxy — works correctly
List<Order> orders = orderCacheService.getOrdersByUser(userId);
return buildSummary(orders);
}
}// Option 3 — Get proxy from ApplicationContext
@Service
public class OrderService implements ApplicationContextAware {
private ApplicationContext context;
@Override
public void setApplicationContext(ApplicationContext context) {
this.context = context;
}
private OrderService getSelf() {
return context.getBean(OrderService.class);
}
public OrderSummary getSummary(Long userId) {
List<Order> orders = getSelf().getOrdersByUser(userId);
return buildSummary(orders);
}
}Note: Option 2 (separate class) is the cleanest approach and avoids circular dependency risks.
Fix 3: Configure Cache Keys Correctly
The default key is all method parameters. Specify keys explicitly for predictable behavior:
// Default key — uses all parameters (method.simpleKey for single param)
@Cacheable("users")
public User getById(Long id) { ... }
// Cache key: SimpleKey[1] for id=1
// Explicit SpEL key
@Cacheable(value = "users", key = "#id")
public User getById(Long id) { ... }
// Composite key from multiple params
@Cacheable(value = "products", key = "#category + '-' + #page")
public List<Product> getByCategory(String category, int page) { ... }
// Key from object field
@Cacheable(value = "users", key = "#user.id")
public User processUser(User user) { ... }
// Key with method name to avoid cross-method collisions
@Cacheable(value = "users", key = "'byEmail:' + #email")
public User getByEmail(String email) { ... }
// Conditional caching — only cache when condition is true
@Cacheable(value = "users", key = "#id", condition = "#id > 0")
public User getById(Long id) { ... }
// Unless — don't cache if result matches condition
@Cacheable(value = "users", key = "#id", unless = "#result == null")
public User getById(Long id) { ... }
// Prevents caching null results (for missing records)Fix 4: Configure CacheEvict and CachePut
Keep the cache in sync with database updates:
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Cacheable(value = "users", key = "#id")
public User getById(Long id) {
return userRepository.findById(id).orElseThrow();
}
// Update cache after saving
@CachePut(value = "users", key = "#result.id")
public User save(User user) {
return userRepository.save(user);
}
// Evict single entry
@CacheEvict(value = "users", key = "#id")
public void deleteById(Long id) {
userRepository.deleteById(id);
}
// Evict all entries in a cache
@CacheEvict(value = "users", allEntries = true)
public void clearAll() { }
// Evict before the method runs
@CacheEvict(value = "users", key = "#user.id", beforeInvocation = true)
public void updateUser(User user) {
userRepository.save(user);
// Even if this throws, cache entry is already evicted
}
// Multiple cache operations
@Caching(
put = { @CachePut(value = "users", key = "#result.id") },
evict = { @CacheEvict(value = "userList", allEntries = true) }
)
public User createUser(User user) {
return userRepository.save(user);
}
}Fix 5: Configure Cache Manager with TTL
In-memory caches grow unbounded without TTL. Configure a proper cache manager:
Caffeine (in-process, high performance):
<!-- pom.xml -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
// Default spec for all caches
manager.setCaffeineSpec(CaffeineSpec.parse("maximumSize=1000,expireAfterWrite=10m"));
return manager;
}
// Or configure per-cache TTL
@Bean
public CacheManager cacheManager() {
SimpleCacheManager manager = new SimpleCacheManager();
List<CaffeineCache> caches = List.of(
buildCache("users", 500, Duration.ofMinutes(30)),
buildCache("products", 1000, Duration.ofHours(1)),
buildCache("sessions", 10000, Duration.ofMinutes(15))
);
manager.setCaches(caches);
return manager;
}
private CaffeineCache buildCache(String name, int maxSize, Duration ttl) {
return new CaffeineCache(name, Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(ttl)
.recordStats() // Enable cache hit/miss statistics
.build());
}
}Redis (distributed, survives restarts):
# application.yml
spring:
cache:
type: redis
redis:
time-to-live: 600000 # 10 minutes in milliseconds (default for all caches)
redis:
host: localhost
port: 6379@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
// Default config for all caches
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()
)
);
// Per-cache overrides
Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
"users", defaultConfig.entryTtl(Duration.ofMinutes(30)),
"sessions", defaultConfig.entryTtl(Duration.ofMinutes(5)),
"products", defaultConfig.entryTtl(Duration.ofHours(2))
);
return RedisCacheManager.builder(factory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}Fix 6: Monitor Cache Effectiveness
Enable logging and metrics to verify the cache is working:
# application.yml — enable cache debug logging
logging:
level:
org.springframework.cache: TRACE// Check cache statistics with Caffeine
@Autowired
private CacheManager cacheManager;
public void printStats() {
CaffeineCache cache = (CaffeineCache) cacheManager.getCache("users");
CacheStats stats = cache.getNativeCache().stats();
System.out.println("Hit rate: " + stats.hitRate());
System.out.println("Hits: " + stats.hitCount());
System.out.println("Misses: " + stats.missCount());
}
// Expose via Spring Boot Actuator
// GET /actuator/caches — lists all caches
// GET /actuator/caches/users — details for 'users' cache
// DELETE /actuator/caches/users — clear 'users' cache# application.yml — enable cache actuator endpoint
management:
endpoints:
web:
exposure:
include: caches, health, infoStill Not Working?
@Cacheable on private or final methods — Spring AOP can’t proxy private or final methods. The annotation is silently ignored. Make the method public and non-final.
Entities not serializable for Redis — when using Redis as the cache store, cached objects must be serializable. If you’re caching JPA entities, they must implement Serializable or you must use a custom serializer. Use DTOs instead of entities to avoid Hibernate lazy-loading issues.
Cache key type mismatch — if the key is generated from an object, ensure the object implements equals() and hashCode() correctly. Two objects that are logically equal but have different hash codes produce different cache keys and result in cache misses.
Testing with @Cacheable — Spring’s test context reuses application context across tests by default. Cache entries from one test may affect another. Use @DirtiesContext or manually clear the cache in @AfterEach:
@AfterEach
void clearCache() {
cacheManager.getCacheNames().forEach(name ->
cacheManager.getCache(name).clear()
);
}For related Spring Boot issues, see Fix: Spring Boot Transaction Not Rolling Back and Fix: Spring Data JPA Query Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Spring Data JPA Query Not Working — @Query, Derived Methods, and N+1 Problems
How to fix Spring Data JPA query issues — JPQL vs native SQL, derived method naming, @Modifying for updates, pagination, projections, and LazyInitializationException.
Fix: Spring Boot @Transactional Not Rolling Back — Transaction Committed Despite Exception
How to fix Spring @Transactional not rolling back — checked vs unchecked exceptions, self-invocation proxy bypass, rollbackFor, transaction propagation, and nested transactions.
Fix: Hibernate LazyInitializationException — Could Not Initialize Proxy
How to fix Hibernate LazyInitializationException — loading lazy associations outside an active session, fetch join, @Transactional scope, DTO projection, and Open Session in View.
Fix: Spring Boot Failed to Configure a DataSource
How to fix 'Failed to configure a DataSource: url attribute is not specified' in Spring Boot — adding database properties, excluding DataSource auto-configuration, H2 vs production DB setup, and multi-datasource configuration.