Skip to main content

The Spring Cache Abstraction: @Cacheable, @CacheEvict, Key Generators and the Self-Invocation Trap

A beginner-first walk through Spring’s application-level caching on Spring Boot 4.1.1: what the interceptor actually does, what SimpleKeyGenerator puts in the map, and the four traps that account for most caching bugs — self-invocation, key collisions, eviction timing, and declarations that only fail at the first call. Every figure comes out of a companion project whose 20 tests produce the transcripts. Includes two corrections: @EnableCaching has no exposeProxy attribute, and the documented cache-provider detection order does not match the enum that ships.

You add @Cacheable to a method. The application starts. Nothing is logged, nothing fails, and the method is still slow. That is the experience most people have with the Spring cache abstraction, and it is not because caching is hard. It is because three annotations hide a proxy, a key generator and a pluggable store, and every one of those three has a default that is reasonable in isolation and surprising in combination. The annotation that does nothing is usually doing exactly what it was told. This article is the walk from “what is a cache” to “why is my cache serving a value no transaction ever committed”, in that order. Everything in it was run: the companion project is asmhatre/spring-boot-demo/caching, where a 20-test suite produces the 22 transcripts under docs/output/ that every figure below is quoted from. If a claim here stops being true, that build goes red.
If you are…Start atYou will get
new to caching in SpringPart 1the mental model, the three annotations, and what is actually stored
using it already and hitting odd behaviourPart 2the four traps that account for most of it, each measured
running it in productionPart 3providers, stampedes, transactions, and the Hibernate boundary
Each part builds on the one before it. In particular, a small fact planted in Part 1 — what a cache key is made of — is the entire explanation for the most expensive bug in Part 2.
Versions. Spring Boot 4.1.1 (the newest GA at the time of writing; the <release> element in Maven Central’s metadata currently points at 4.2.0-M1, which is a milestone, not a release), Spring Framework 7.0.9, Caffeine 3.2.4 (as managed by Boot), JDK 25 (Temurin 25.0.4.1+1), Maven 3.9. Annotation attributes were read out of the class files with javap rather than from documentation.

Part 1 — What it is, and the smallest thing that works

A cache hit is a method that did not run

Start with the only definition that matters. The Spring cache abstraction is an interceptor and a map interface. When a bean carries @Cacheable, Spring does not rewrite the class. It puts a proxy in front of the bean and a CacheInterceptor in the proxy. On every call the interceptor asks three questions.
callercontroller the proxy Spring injected CacheInterceptor1. build a key 2. look it up 3. decide your bean (the target)findBook(String isbn) { … } Cache "books"978-0134685991 → Book[…]978-1617294945 → Book[…]a ConcurrentHashMap, or Caffeine, or Redis get only on a miss The interceptor lives in the proxy, not in your class. Remember that sentence; Part 2 is built on it. Everything a cache product does — expiry, size limits, eviction, replication — lives on the right-hand box, behind the Cache interface. The abstraction itself has none of it.
Here is the smallest complete example. A repository stub that sleeps 200 ms and counts its own invocations, and a service with one annotation:
@Service
public class BookService {

    private final BookRepositoryStub repository;

    public BookService(BookRepositoryStub repository) {
        this.repository = repository;
    }

    @Cacheable("books")
    public Book findBook(String isbn) {
        return repository.load(isbn);   // 200 ms
    }
}
Plus one line somewhere in a @Configuration class — and it does have to be somewhere, because the annotations alone do nothing:
@Configuration
@EnableCaching
public class CacheConfig {
}
Calling findBook twice with the same ISBN produces this, from docs/output/01-basics.txt:
--- first call (miss) ---
returned            : Book[isbn=978-0134685991, title=Effective Java, year=2018]
elapsed             : 200 ms
repository calls    : 1

--- second call (hit) ---
returned            : Book[isbn=978-0134685991, title=Effective Java, year=2018]
elapsed             : 0 ms
repository calls    : 1   <- still 1, the method body never ran
same object?        : true
Note the last line as much as the timing. A cache hit returns the same instance, not a copy. Every caller shares one object. That is free and fast, and it is a bug waiting to happen if the object is mutable — which is the first reason the checklist at the end says to cache immutable records.
Spring Boot’s own advice: do not put @EnableCaching on your main application class. Doing so makes caching mandatory in every test slice as well. Give it its own @Configuration class that you can exclude — and when you suspect the cache of something, you can disable the entire feature by deleting one line instead of hunting down every annotation.

Three annotations, three intentions

AnnotationRuns the method?Touches the cacheUse it when
@Cacheableonly on a missreads, then writes on a missreading something expensive
@CachePutalwayswrites the return valueyou have just produced the new value
@CacheEvictalwaysremoves one key, or the whole regionsomething changed and you want the next reader to reload
What each annotation does with one call @Cacheable check cache method (miss only) put (miss only) hit → neither of the dashed boxes runs @CachePut method (always) put never reads the cache first @CacheEvict method (always) evict … if the method returned normally That last qualifier is the subject of Part 2. An eviction is skipped when the method throws, which is precisely the moment you most wanted it to happen.

What is actually in there

The single most useful thing a beginner can do is look at the map. This project ships a diagnostic endpoint that walks the CacheManager and prints every entry with the runtime class of the key and the value. From docs/output/23-diagnostics.txt:
"shapes": {
    "implementation": "org.springframework.cache.concurrent.ConcurrentMapCache",
    "nativeStore": "java.util.concurrent.ConcurrentHashMap",
    "entries": {
        "SimpleKey []   [SimpleKey]": "zero   [String]",
        "SimpleKey [abc, 7]   [SimpleKey]": "two:abc:7   [String]",
        "abc   [String]": "one:abc   [String]"
    }
}
Three calls, three keys, three shapes. That is the default key generator, SimpleKeyGenerator, and it has exactly three rules:
Method argumentsKey
nonethe constant SimpleKey.EMPTY, printed as []
onethat argument itself, unwrapped
two or morea SimpleKey holding all of them
Plant this and carry it into Part 2. The method name is not in the key. Neither is the declaring class. A cache key is built from the arguments and nothing else — which means two different methods can, and eventually will, collide.

The keys chapter in the repository has the full SpEL surface available to key, condition and unless#root.methodName, #root.args, #result and the rest — along with what changes once the cache is Redis rather than a map.

One more beginner-level rule: when not to cache

A cache is a deliberate decision to serve stale data in exchange for latency. It is the wrong trade when the method is not measurably slow, when the hit rate will be low (a cache keyed on free-text search is a memory leak in a performance costume), or when the data has to be correct right now — balances, stock levels, permissions. Chapter 1 of the repository, what the cache abstraction is, expands on all four cases.

Part 2 — The four traps, measured

Everything so far works. This is where it stops working, and the order matters: the first trap is the one that produces “my annotation does nothing”, and the second is the one that produces “my cache returns the wrong answer”, which is considerably worse.

Trap 1: the self-invocation trap

Here is a service that looks entirely reasonable and caches nothing at all:
@Service
public class CatalogService {

    @Cacheable("catalog")
    public Book lookup(String isbn) {
        return repository.load(isbn);
    }

    public List<Book> byInternalCall(List<String> isbns) {
        return isbns.stream().map(this::lookup).toList();   // caches nothing
    }
}
Go back to the first diagram. The interceptor is in the proxy. this.lookup(...) is an ordinary virtual call on the target object, and the target object has never heard of caching.
CatalogService$$SpringCGLIB$$0 (the proxy) CacheInterceptor CatalogService (the target) byInternalCall(..)not annotated lookup(..)@Cacheable controllercalls in crosses the interceptor this.lookup(..) — never leaves the target, never reaches the interceptor Four ISBNs with two repeats. A working cache does two repository lookups. this.lookup(..) → 4 proxy.lookup(..) → 2
Measured, from docs/output/03-self-invocation.txt:
injected bean class : com.ankurm.caching.selfinvocation.CatalogService$$SpringCGLIB$$0
is an AOP proxy?    : true
target class        : com.ankurm.caching.selfinvocation.CatalogService

this.lookup(..)                 -> 4 repository calls   <- no caching at all
self.getObject().lookup(..)     -> 2 repository calls
AopContext.currentProxy()       -> 2 repository calls
a second bean calls lookup(..)  -> 2 repository calls
FixCostVerdict
Move the loop into a different beanone more classreach for this one. No Spring-specific machinery, and the two responsibilities were different anyway
Inject yourself as ObjectProvider<T> (or @Lazy T self)a slightly odd-looking fieldfine, and honest about what it is doing
AopContext.currentProxy()a ThreadLocal read and a cast in business codeworks, but see the callout below
@EnableCaching(mode = AdviceMode.ASPECTJ) with weavingbuild complexitya real answer that almost nobody takes for caching alone
A widely copied line that does not compile. Plenty of answers tell you to write @EnableCaching(exposeProxy = true) so that AopContext.currentProxy() works. @EnableCaching has exactly three attributes, and I checked the class file rather than the documentation:

AdviceMode mode()  ·  int order()  ·  boolean proxyTargetClass()

There is no exposeProxy. @EnableAspectJAutoProxy(exposeProxy = true) is the usual workaround and pulls in AspectJ; the self-invocation chapter shows a three-line BeanFactoryPostProcessor that flips the flag on the auto-proxy creator @EnableCaching already registered, with no extra dependency.
Two relatives of the same bug, both silent, both in docs/output/04-non-public-and-postconstruct.txt: in proxy mode a non-public annotated method is never advised (two calls, two repository hits, no warning), and @PostConstruct runs before the proxy is in place, so a warm-up loop in an init method warms nothing. The ten-second diagnosis: print AopUtils.isAopProxy(bean) and bean.getClass().getName(). No $$SpringCGLIB$$ means there is no interceptor and nothing downstream matters. If it is proxied and the cache is still empty, the call is not going through it — look for this..

Trap 2: the key collision

This is the payoff for the fact planted in Part 1. The key is built from the arguments and nothing else. So:
@Cacheable("shared")
public String countLetters(String input) { ... }

@Cacheable("shared")
public String countDigits(String input) { ... }
countLetters(“a1b2”)runs, returns letters=2 countDigits(“a1b2”)never runs key generatorone argument→ the argument itselfkey = “a1b2”method name: not used cache “shared”“a1b2” → letters=2one entry, two methods countDigits returns letters=2. No exception, no log line, no test failure unless something asserts the actual value — which is why this one reaches production. Two no-argument methods in one cache collide even harder: both key on SimpleKey.EMPTY.
From docs/output/06-key-collision.txt:
countLetters("a1b2") -> letters=2   (repository calls: letters=1 digits=0)
countDigits("a1b2")  -> letters=2   (repository calls: letters=1 digits=0)

  cache "shared":
    key a1b2                   [String]   ->  letters=2
In a real codebase this looks like findByIsbn and findByTitle sitting next to each other in the same service, both annotated @Cacheable("books"), both taking a String. Use one cache name per method. It costs nothing, it makes a cache dump readable, and it hands you per-method TTL and per-method metrics for free. The alternatives — putting the method into the key with key = "'letters:' + #input", or a custom KeyGenerator that includes method.getName() — are in the keys chapter, together with a related leak: pass a mutable List as an argument, mutate it afterwards, and the entry is stranded under a hash code the map no longer agrees with. Unreachable, unevictable, and still in the next heap dump.

Trap 3: eviction happens after the method, and only if it succeeds

@CacheEvict defaults to beforeInvocation = false. Read that as: evict after the method returns normally. A method that throws does not evict.
updatePrice writes 250 to the store, then throws default store ← 250 throws evict (skipped) cache still serves 100, forever beforeInvocation evict first store ← 250 throws next read reloads: 250 An eviction that fires too early costs one cache miss. An eviction that never fires costs correctness, and nothing will notice. For anything that mutates, beforeInvocation = true is the better default.
docs/output/08-evict-timing.txt runs both:
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

--- beforeInvocation = true ---
updatePriceEvictFirst("sku-2", 250, fail=true) threw the same way.
price("sku-2")    : 250   <- the entry went first, so the next read is honest
@CachePut is the third option: no miss at all, because it writes your computed value straight into the cache. It is also the most dangerous of the three, because it never reads the store back — any transformation the database applies (a trigger, a default, a truncation) is invisible to every subsequent reader. The eviction chapter compares all three and covers allEntries = true and CacheErrorHandler, which decides whether a Redis timeout degrades your application or takes it down.

Trap 4: most bad declarations fail at the first call, not at startup

Five deliberately wrong declarations were run through a real context. Only one of them failed while the context was starting — docs/output/20-invalid-declarations.txt:
DeclarationFailsWith
key and keyGenerator togetherat startupBoth 'key' and 'keyGenerator' attributes have been set…
sync = true with unlessfirst callA sync=true operation does not support the unless attribute
sync = true across two cachesfirst callA sync=true operation is restricted to a single cache
sync = true plus another cache operationfirst callA sync=true operation cannot be combined with other cache operations
a cache name spring.cache.cache-names does not declarefirst callCannot find cache named 'unknown'
Four of five start a healthy-looking application and throw on a code path that may not run for hours. That is the strongest argument available for having one integration test that actually calls every cached method once.

Part 3 — What the defaults do not do

The abstraction has no TTL, and your provider may not be the one you think

No time-to-live, no time-to-idle, no size limit, no eviction policy. The reference documentation says so plainly, and it follows from the first diagram: all of that lives behind the Cache interface, in a provider. On the default simple provider — a ConcurrentHashMap — an entry stays until something evicts it by hand or the process ends. That is fine for a twelve-row lookup table and a slow leak for everything else. Which provider you get, if you have not said, is decided by the classpath — and by an order that is worth reading out of the enum rather than out of the documentation:
No CacheManager bean? The configurations are imported in CacheType declaration order. The first one whose class condition matches registers the manager; the rest back off. 1 Genericabsent 2 JCacheabsent 3 Hazelcastabsent 4 Couchbaseabsent 5 Infinispanabsent 6 Redisabsent 7 Cache2kabsent 8 Caffeinepresent 9 Simple CaffeineCacheManagerthe simple provider is never reached Caffeine was added to this module for one chapter that needed a TTL. Note positions 7 and 8. The reference documentation lists Caffeine ahead of Cache2k; the enum in 4.1.1 has it the other way round, so with both libraries present you get Cache2k. Couchbase and Infinispan are swapped too. Nothing logs any of this. Set spring.cache.type explicitly in anything you deploy.
spring.cache.type          : (not set)
resolved CacheManager bean : org.springframework.cache.caffeine.CaffeineCacheManager

--- the detection order, read out of the enum rather than the documentation ---
  1 GENERIC
  2 JCACHE
  3 HAZELCAST
  4 COUCHBASE
  5 INFINISPAN
  6 REDIS
  7 CACHE2K
  8 CAFFEINE
  9 SIMPLE
  10 NONE
The documented order and the shipped order disagree. Spring Boot’s reference page lists the detection order as Generic, JCache, Hazelcast, Infinispan, Couchbase, Redis, Caffeine, Cache2k, Simple. CacheConfigurations holds an EnumMap<CacheType, String>, so the real order is the declaration order of the CacheType enum — and on 4.1.1 that swaps Couchbase with Infinispan and, more usefully, puts Cache2k ahead of Caffeine. With both on the classpath you get Cache2k. The test that produced the list above reads CacheType.values() rather than transcribing anything, so it will notice if this changes again.
With Caffeine configured for expireAfterWrite=400ms, maximumSize=3, the entry expires and the size bound holds — docs/output/15-providers-and-ttl.txt has the numbers, including the hits=1 misses=5 evictions=3 line that only exists because the cache was built with recordStats(). The same file shows a cache built without it reporting hitCount=0, missCount=0 after a hit and a miss — which is what Micrometer’s cache.gets will report too, and looks exactly like a cache nobody is using. The providers chapter has the full property table, the reason a single spring.cache.caffeine.spec cannot give you per-cache expiry, and one Boot 4 detail worth knowing if you write exclusions by class name: caching auto-configuration moved to org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration in the new spring-boot-cache module, and the Boot 3 coordinate no longer resolves.

The stampede, and the restrictions on the cure

Sixteen threads, one cold key, a method that sleeps 300 ms — docs/output/11-stampede.txt:
@Cacheable("reports")                     -> 16 invocations
@Cacheable("syncedReports", sync = true)  -> 1 invocation
Nothing is wrong with the first line; it is doing what it was told. The problem is when it happens — right after a deployment, right after an eviction, exactly when the cache was supposed to be helping. sync = true makes one caller compute while the rest block on the same computation. Its four restrictions all fire at the first call rather than at startup, which is trap 4 again; they are in the sync chapter along with CompletableFuture and reactive return types, supported since Spring Framework 6.1. That last one has a failure mode worth seeing, because the application starts perfectly and then does this at the first call:
java.lang.IllegalStateException: No Caffeine AsyncCache available: set CaffeineCacheManager.setAsyncCacheMode(true)

A rollback does not roll the cache back

This is the one that costs the most to debug, because the symptom appears somewhere else entirely. The caching interceptor runs inside the transaction interceptor. A cache write therefore happens at method exit — before the commit, and with no knowledge of whether the commit will succeed.
TransactionInterceptor — outermost CacheInterceptor — inside it rename(id, newName)updates the row, returns normally↓ the @CachePut fires here cache1 → “Alice Cooper”written, and kept the next step throws → ROLLBACK database: “Alice” The cache is now authoritative for a value no transaction ever committed.
docs/output/13-transactions.txt:
An outer @Transactional method calls the @CachePut update, which succeeds,
and then fails on the next step. The transaction rolls back.

what the cache serves : Alice Cooper
what the database has : Alice
The same shape expressed as an eviction self-heals: the entry is gone, the next read goes to the database and repopulates correctly. An eviction that fires too early costs a lookup; a put that fires too early costs correctness. When in doubt, evict. Wrapping the manager in TransactionAwareCacheManagerProxy defers every put and evict to a post-commit synchronisation, and the identical rollback then leaves the cache saying Alice. It does not make the cache transactional — reads are never deferred, and outside a transaction it is a pass-through.
A correction to something widely repeated. spring-framework#23192, “@CacheEvict beforeInvocation with transaction does not work”, reported that beforeInvocation = true was swallowed by the transaction-aware decorator, deferring the eviction to commit — the opposite of what the attribute asks for. It is still quoted as current behaviour. I wrote it into this article on that basis, then measured it, and the measurement came back the other way.

The issue is closed, against milestone 5.2 RC1, and on 7.0.9 the behaviour it describes does not reproduce. AbstractCacheInvoker.doEvict(cache, key, immediate) calls evictIfPresent() on the immediate path and evict() otherwise, and TransactionAwareCacheDecorator only registers a synchronisation inside evict(). The javap output is committed as docs/output/22-decorator-bytecode.txt. Worth a note if you are carrying that assumption around: it was fixed.
The transactions chapter has the full pattern. If you want the ordering from the other side, @Transactional: propagation, isolation and the silent failures covers the interceptor stack from the transaction end.

This is not the Hibernate second-level cache

If you use JPA you already have caching, at a different layer, with different rules. Conflating the two produces designs that cache the wrong thing.
Spring cache abstraction — service layer stores: whatever object the method returned, under a key you chose invalidated by: you, with @CacheEvict. It does not know your database exists. Hibernate second-level cache — SessionFactory-wide stores: dehydrated entity state, keyed by entity id; rehydrated into a managed entity invalidated by: Hibernate, automatically, on write Persistence context (first level) — one transaction stores: managed entity instances, identity map. Always on, nothing to configure. Only the middle layer understands writes. The top layer caches an object graph and forgets where it came from.
The practical difference shows up the first time you cache an entity. Hibernate’s L2 rehydrates state into a live, session-attached entity, so lazy associations still work. @Cacheable stores the object as it was when the transaction closed — detached — and hands the same instance to every caller. From docs/output/14-cached-entity.txt:
second call returns the same instance? true

--- touching the lazy collection outside the session ---
org.hibernate.LazyInitializationException
  Cannot lazily initialize collection of role 'com.ankurm.caching.jpa.Customer.orders' with key '1' (no session)
Cache DTOs, not entities. Map to a record inside the transaction and cache that: immutable, no session affinity, serialises cleanly to a distributed cache, and the cached shape becomes a decision rather than an accident of your mapping.

If what you actually want is entity caching by id, invalidated automatically on write, that is exactly what Hibernate’s second-level cache is for, and it will do it better than @Cacheable because it understands the writes. Hibernate 7 second-level cache: when to turn it on, how to configure Ehcache 3, and the three ways it will stale your data is the companion piece; the first-level cache covers the layer below it.
The comparison chapter has the full table and a which-one-to-reach-for matrix. The short version: use Hibernate L2 to avoid re-loading entities by id, and the Spring cache abstraction to avoid re-running an expensive computation, HTTP call, or DTO assembly.

The long tail

Each of these has a paragraph and a reproduction in the repository:
  • null is cached by default, as a NullValue sentinel — usually what you want, since refusing to cache misses is how cache-penetration floods get through. chapter 6
  • condition vetoes before the call, unless after it — only unless can see #result, and only condition can skip the lookup. chapter 6
  • A mutable argument used as a key strands its entry under a hash code the map no longer agrees with. chapter 4
  • allEntries = true is a blunt instrument in a shared cache, and may be an O(n) keyspace operation on a distributed provider. chapter 5
  • The default CacheErrorHandler rethrows, so a Redis timeout becomes an application error unless you decide otherwise — per cache, not per application. chapter 5
  • spring.cache.type=none is the fastest bisect available — a NoOpCacheManager, annotations untouched. If the bug survives, it was never the cache. chapter 11
  • A changed DTO shape with old entries still in Redis fails on read, per instance, during a rolling deployment. chapter 12

Should you have added this cache at all?

Often, no. A cache is a correctness liability accepted in exchange for latency, and half the caches in a typical codebase were added without a measurement and never revisited. If the method is not measurably slow, if the hit rate will be low, or if the data has to be current, the right amount of caching here is none — and you will have spent nothing on the four traps above.

When the answer is yes, four rules cover most of what goes wrong: give every cache a TTL, evict rather than put, cache DTOs rather than entities, and set spring.cache.type explicitly. The production checklist is the long form.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.