Caching in Spring Boot Without Serving Stale Data
Spring's cache abstraction is three annotations and a switch. That simplicity is why it gets added to a codebase in an afternoon and why it starts serving wrong data a month later. The annotations are easy. Deciding what may be cached, and for how long, is the actual work.
The setup
Enable it once, then annotate:
@SpringBootApplication
@EnableCaching
public class Application { }
@Service
public class ProductService {
@Cacheable("products")
public Product findById(Long id) {
return productRepository.findById(id).orElseThrow();
}
}
Without @EnableCaching, every annotation in the codebase is inert and nothing warns you. That is the first thing to check when a cache "is not working".
How the three annotations divide up
@Cacheable looks first and calls the method only on a miss. @CachePut always calls the method and always writes the result, which is what you want on an update. @CacheEvict removes an entry, and with allEntries = true clears the whole cache.
@CachePut(value = "products", key = "#product.id")
public Product update(Product product) {
return productRepository.save(product);
}
@CacheEvict(value = "products", key = "#id")
public void delete(Long id) {
productRepository.deleteById(id);
}
The critical detail on @CachePut: it must return the updated object, and the key must match the key @Cacheable used. A @CachePut keyed on #product when the read is keyed on #id writes a second entry and leaves the stale one in place.
The same proxy rule as everything else
Caching is implemented with a proxy, so an internal call from one method of a bean to another skips it entirely. This is the identical trap that makes @Transactional appear to be ignored, and it produces the same confusing symptom: the annotation is right there, and the method runs every time.
Keys
The default key is derived from all parameters. That is fine for a single argument and wrong as soon as one argument does not affect the result:
@Cacheable(value = "products", key = "#id")
public Product findById(Long id, boolean includeAudit) { ... }
Two rules that save real incidents. First, never let an object with a default equals be part of a key, for the same reason the equals and hashCode contract matters for map keys: every lookup misses and the cache grows without bound. Second, if the result depends on the caller (a tenant, a user, a locale), that input must be in the key. A cache that ignores the tenant will eventually hand one customer another customer's data, and it is the worst bug in this entire area.
Conditions
condition is evaluated before the call, unless after, against the result.
@Cacheable(value = "products", unless = "#result == null")
public Product findById(Long id) { ... }
Caching nulls is sometimes deliberate, because it stops a hammering client from hitting the database for a row that does not exist. Doing it by accident, with an unbounded key space, is a memory leak.
Local or distributed
The default ConcurrentMapCacheManager is an in-memory map with no eviction and no size limit. It is acceptable for a small fixed set of reference data and dangerous for anything keyed on user input.
Caffeine gives you bounds and expiry, and needs only the dependency plus configuration:
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=10m
For more than one instance, the choice is between accepting that each instance has its own slightly different copy, or using Redis so they share one. Local caches are faster and cannot be invalidated across the fleet. Redis is invalidatable and adds a network hop plus a serialization format you now have to version.
Invalidation, honestly
Every cache is a bet that stale data is acceptable for some window. Make the bet explicit. A TTL is a statement about how wrong you are willing to be, and it is more reliable than eviction logic, because eviction logic breaks the moment a row is updated by a path that does not go through your service: a batch job, a migration, another service, a support engineer with a SQL client.
Cache slow-changing reference data with a TTL, evict aggressively on your own writes as a courtesy rather than a guarantee, and never cache anything where being ten minutes out of date would be a correctness problem rather than a cosmetic one.