Spring Boot's @Cacheable is powerful but easy to misuse. Returning mutable objects, caching null, and wrong key strategies silently corrupt your cache. Here's how to get it right.
JOptimize Team
Spring Boot's @Cacheable is one of those annotations that looks simple and hides serious complexity. Used correctly, it's a powerful performance tool. Used incorrectly, it silently serves stale or corrupted data, causes hard-to-reproduce bugs, and creates memory leaks.
The most dangerous @Cacheable mistake - returning a mutable object that callers can modify:
// DANGEROUS - callers can mutate the cached object @Cacheable("users") public User findById(Long id) { return userRepository.findById(id).orElseThrow(); } // Somewhere in your code: User user = userService.findById(1L); user.setEmail("hacked@example.com"); // Mutates the CACHED object! // Next call returns the mutated version from cache User same = userService.findById(1L); // email = "hacked@example.com" !!!
The cache stores the object reference (with most in-memory caches like Caffeine/ConcurrentHashMap). Any caller mutating the returned object directly corrupts the cached value.
Fix: return immutable objects or defensive copies:
// Option 1: Return a DTO (immutable record) public record UserDto(Long id, String name, String email) {} @Cacheable("users") public UserDto findById(Long id) { User user = userRepository.findById(id).orElseThrow(); return new UserDto(user.getId(), user.getName(), user.getEmail()); // ? } // Option 2: Return an unmodifiable copy @Cacheable("products") public List<Product> findAllActive() { return Collections.unmodifiableList(productRepository.findByActiveTrue()); // ? }
// BUG - all users share the same cache entry! @Cacheable("users") // No key specified public User findByIdAndTenant(Long id, String tenantId) { return userRepository.findByIdAndTenantId(id, tenantId).orElseThrow(); } // First call: findByIdAndTenant(1L, "tenant-A") ? caches under key=[1, "tenant-A"] // BUT default key is SimpleKey([1, "tenant-A"]) which works - // the REAL risk is when you do: @Cacheable("users") // Only caches on id - ignores tenantId! public User findByIdAndTenant(Long id, String tenantId) { return repo.findByIdAndTenantId(id, tenantId).orElseThrow(); } // tenant-B gets tenant-A's data!
Always specify explicit keys:
@Cacheable(value = "users", key = "#id + ':' + #tenantId") public User findByIdAndTenant(Long id, String tenantId) { return userRepository.findByIdAndTenantId(id, tenantId).orElseThrow(); // ? } // For complex keys, use SpEL: @Cacheable(value = "reports", key = "#filter.type + ':' + #filter.startDate + ':' + #filter.endDate") public Report generateReport(ReportFilter filter) { ... }
// Without unless, null results are cached and hide real errors @Cacheable("users") public User findByEmail(String email) { return userRepository.findByEmail(email).orElse(null); // Caches null! } // Later: user registers with that email // findByEmail still returns null from cache - user "doesn't exist"
Fix with unless condition:
@Cacheable(value = "users", key = "#email", unless = "#result == null") public User findByEmail(String email) { return userRepository.findByEmail(email).orElse(null); // ? Null not cached } // Or throw instead of returning null: @Cacheable(value = "users", key = "#email") public User findByEmail(String email) { return userRepository.findByEmail(email) .orElseThrow(() -> new UserNotFoundException(email)); // Exceptions aren't cached }
@Service public class UserService { @Cacheable("users") public User findById(Long id) { ... } public User updateEmail(Long id, String newEmail) { User user = userRepository.findById(id).orElseThrow(); user.setEmail(newEmail); return userRepository.save(user); // Cache still holds old email! findById returns stale data. } }
Always evict or update cache on mutations:
@CacheEvict(value = "users", key = "#id") public User updateEmail(Long id, String newEmail) { User user = userRepository.findById(id).orElseThrow(); user.setEmail(newEmail); return userRepository.save(user); // ? Cache cleared on update } // Or use @CachePut to update the cache with the new value: @CachePut(value = "users", key = "#result.id") public User updateEmail(Long id, String newEmail) { User user = userRepository.findById(id).orElseThrow(); user.setEmail(newEmail); return userRepository.save(user); // ? Cache updated with new object }
For production, use Caffeine with explicit TTL and size limits:
# application.properties spring.cache.type=caffeine spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=5m
Or per-cache configuration:
@Configuration public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager manager = new CaffeineCacheManager(); manager.registerCustomCache("users", Caffeine.newBuilder() .maximumSize(5000) .expireAfterWrite(10, TimeUnit.MINUTES) .recordStats() // Enable Micrometer metrics .build()); manager.registerCustomCache("reports", Caffeine.newBuilder() .maximumSize(100) .expireAfterWrite(1, TimeUnit.HOURS) .build()); return manager; } }
@Cacheable on @Transactional methods - the cache may store results from an uncommitted transaction; use @TransactionalEventListener(AFTER_COMMIT) to evict/populate cache only after commitPage<T> objects are not serializable; cache the content list separatelymaximumSize, Caffeine grows unbounded and causes OutOfMemoryError under sustained load@Cacheable on private methods - same AOP proxy issue as @Transactional; only works on public methods called from outside the class@Cacheable anti-patterns are subtle - mutable return objects corrupt cache state, wrong keys leak data across tenants, uncached nulls cause stale reads, and missing @CacheEvict serves stale data after updates. Always return immutable types from cached methods, specify explicit keys, handle nulls with unless, and pair every write operation with a cache eviction.
JOptimize flags mutable return types in @Cacheable methods, missing @CacheEvict on update methods, and @Cacheable on private methods - the patterns that silently corrupt your cache in production.
Find cache corruption bugs before they serve stale data in production - free scan, no configuration required.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.