Cache Invalidation Is a Write-Path Problem
Most stale-cache bugs are not caching bugs. They are write paths that forgot they had readers. Here is how to make invalidation structural instead of remembered.
Almost every stale-cache incident I have reviewed has the same shape. The read path is fine. Someone added a cache, it worked, latency dropped. Months later a new write path appears — a bulk import, an admin action, a scheduled job — and nobody evicts anything, because eviction was never part of what a write is.
That is the actual failure: invalidation was treated as a thing you remember to do, rather than a property of the write.
The version that breaks
Here is the pattern that causes it.
@Cacheable(cacheNames = "projects", key = "#slug")
public ProjectView findBySlug(String slug) {
return repository.findBySlugAndPublishedTrue(slug)
.map(mapper::toView)
.orElseThrow(() -> new ResourceNotFoundException("Project", slug));
}
@CacheEvict(cacheNames = "projects", key = "#project.slug")
public Project update(Project project) {
return repository.save(project);
}
This looks correct and is not. The eviction only covers the one entry keyed by that slug. It misses:
- the
projectListcache holding the paginated listing this project appears in - the
featuredProjectscache, if the featured flag changed - the entry under the old slug, if the slug itself was edited
- anything written by a different service that also saves projects
Every one of those is a separate incident waiting for a different trigger.
Route writes through one place
The fix is not a longer annotation. It is making every write to a content type go through a single method that owns both the persistence and the invalidation.
@Service
@RequiredArgsConstructor
public class ProjectCommandService {
private final ProjectRepository repository;
private final ContentCacheManager cacheManager;
@Transactional
public Project save(Project project, String previousSlug) {
Project saved = repository.save(project);
cacheManager.evictProjectContent(saved.getSlug(), previousSlug);
return saved;
}
}
And the cache manager owns the full blast radius in one readable place:
public void evictProjectContent(String slug, String previousSlug) {
evictEntry(CacheNames.PROJECT_DETAIL, slug);
if (previousSlug != null && !previousSlug.equals(slug)) {
evictEntry(CacheNames.PROJECT_DETAIL, previousSlug);
}
clear(CacheNames.PROJECT_LIST);
clear(CacheNames.FEATURED_PROJECTS);
clear(CacheNames.SITEMAP);
}
Now when someone adds the sixth write path, they call save, and invalidation comes with it. They do not have to know the cache topology. That is the point.
Clear the list, key the detail
A recurring question is whether to evict list caches surgically or clear them wholesale.
For a content site, clear them. The listing caches are small, they rebuild in a single query, and admin writes are rare — a few dozen a day against thousands of reads. Spending engineering effort on surgical list invalidation buys you nothing measurable and costs you a class of bug that is genuinely hard to reproduce.
Reserve keyed eviction for detail entries, where the key is unambiguous and the entry is expensive to rebuild.
Never cache the things that hurt
Three categories stay uncached regardless of read volume:
- Anything behind authentication. A cache keyed without the principal will eventually serve one user's data to another. If you must cache per-user, the principal goes in the key, and you should be certain it is worth it.
- Anything you allocate. Availability can be cached; the allocation decision cannot. The moment a cached read decides who gets the last unit, you have a correctness bug on a timer.
- Anything you have not measured. A cache is a permanent invalidation obligation. Pay it for reads you have actually observed to be hot.
Give every cache a TTL anyway
Even with correct eviction, set a TTL. Not because you expect eviction to work — because you expect it eventually not to.
spring:
cache:
redis:
time-to-live: 10m
cache-null-values: false
A TTL converts an invalidation bug from permanent corruption into a bounded window. It is the cheapest insurance in the system, and the one thing that determines whether a missed eviction is a ten-minute annoyance or a support ticket six weeks later.
The test that matters
Assert the invariant directly: after a write, a read returns the new value.
@Test
void updatingProjectInvalidatesCachedDetail() {
service.findBySlug("example"); // populate
Project project = repository.findBySlug("example").orElseThrow();
project.setTitle("Updated Title");
commandService.save(project, "example");
assertThat(service.findBySlug("example").title())
.isEqualTo("Updated Title");
}
This test fails the moment someone adds a write path that bypasses the command service. That is precisely the regression you want to catch, and it is worth more than any amount of documentation about which caches to clear.