Files
asmhatre a9867c0423 Add caching: the Spring cache abstraction, keys, eviction timing and the self-invocation trap
A Spring Boot 4.1.1 module whose test suite is the evidence for the article: 20 tests
producing 22 transcripts under docs/output/, plus 12 documentation chapters.

Findings the build pins:
- @EnableCaching has no exposeProxy attribute; the widely-copied
  @EnableCaching(exposeProxy = true) does not compile.
- Two methods sharing a cache name and an argument type share a key space, and one
  silently serves the other's answers.
- The documented cache-provider detection order does not match CacheType's enum
  order in 4.1.1: COUCHBASE before INFINISPAN, and CACHE2K before CAFFEINE.
- beforeInvocation = true is NOT deferred by TransactionAwareCacheManagerProxy on
  7.0.9 - doEvict picks evictIfPresent, which the decorator does not intercept.
- Four of five invalid declarations start a clean context and throw at the first call.
- Caffeine on the classpath silently displaces the simple provider.
2026-09-12 05:35:13 +00:00

3.1 KiB

← keys · next: conditions and nulls →

5. Eviction, and when it happens

@CacheEvict defaults to beforeInvocation = false, which means: evict after the method returns normally. A method that throws does not evict.

From docs/output/08-evict-timing.txt:

price("sku-1") -> 100   (stored price is 100)

updatePrice("sku-1", 250, fail=true) threw after writing the new price.
stored price now  : 250
price("sku-1")    : 100   <- the cache still serves the old value

The write to the real store happened. The exception came afterwards. The eviction did not run, so the cache is now authoritative for a value that no longer exists anywhere else. Nothing will correct it: the next reader gets 100, and the one after that, until something evicts the entry or the process restarts.

beforeInvocation = true makes the same failure harmless:

updatePriceEvictFirst("sku-2", 250, fail=true) threw the same way.
price("sku-2")    : 250   <- the entry went first, so the next read is honest

Choosing between the three shapes

Shape Cost Correct when the write fails?
@CacheEvict (default) one miss on the next read no — stale entry survives
@CacheEvict(beforeInvocation = true) one miss, plus a window where two callers can both miss yes
@CachePut no miss at all no — and it writes a value the store may not have

@CachePut is the fastest and the most dangerous: it puts your computed value into the cache without reading the store back, so any transformation the database applies — a trigger, a default, a truncation, a generated column — is invisible to every subsequent reader. Use it when the method's return value is definitively the new state.

beforeInvocation = true is the right default for anything that mutates. The extra miss costs one lookup; the alternative costs an incident.

allEntries = true

Clears the region in a single operation rather than key by key, which matters when the region is large or remote. Two things to know:

  • It is a blunt instrument in a shared cache: one bulk import evicts everything every other caller warmed.
  • On a distributed provider clear() may be an O(n) scan or a whole-keyspace operation. Check what your provider does before putting it on a frequently called method.

Errors from the cache itself

If the cache provider throws — a Redis timeout, a serialization failure — the default SimpleCacheErrorHandler rethrows, so a cache outage becomes an application outage. A CacheErrorHandler registered through CachingConfigurer can log and continue instead:

@Configuration
@EnableCaching
class CacheConfig implements CachingConfigurer {
    @Override
    public CacheErrorHandler errorHandler() {
        return new SimpleCacheErrorHandler() { /* log and swallow get/put/evict errors */ };
    }
}

That is the right call for a read-through cache in front of a database, and the wrong call when the eviction is what keeps two systems consistent — a swallowed evict error is a permanently stale entry. Decide per cache, not per application.