Caching is the cheapest performance win available and the easiest way to serve confidently wrong data. The annotations are three lines; deciding what to cache and when to throw it away is the actual work.
Setup
<!-- Spring Boot 4 modularized autoconfiguration: depending on the cache
abstraction alone gives you the annotations with NO CacheManager, and
@Cacheable then silently does nothing. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>@Configuration
@EnableCaching
public class CacheConfig {
/** The active, customer-facing menu. */
public static final String MENU_CACHE = "menu";
/** The active menu filtered to one product type. */
public static final String MENU_BY_TYPE_CACHE = "menuByType";
@Bean
public ConcurrentMapCacheManager cacheManager() {
ConcurrentMapCacheManager manager =
new ConcurrentMapCacheManager(MENU_CACHE, MENU_BY_TYPE_CACHE);
// Refuse to create caches that were never declared above. Without this, a typo in a
// @Cacheable name quietly creates a brand new cache that no @CacheEvict ever clears.
manager.setAllowNullValues(false);
return manager;
}
}Without @EnableCaching, every annotation is inert. No error, no
warning — the methods simply run every time. It is the first thing to check when caching "isn't
working".
Decide what to cache before you cache it
The pizza API caches the menu and nothing else, and the comment says why:
/**
* <p>The menu is the right thing to cache and almost the only one here: every visitor loads it, it
* is identical for all of them, and it changes only when an administrator edits the catalogue. The
* cart and the orders are cached nowhere — they are per-user and change constantly, and caching
* them would be a correctness bug dressed as an optimisation.
*/Three properties make something a good candidate: read often, identical for every caller, changes rarely. The menu has all three. A cart has none of them.
Per-user data is the dangerous case, because caching it usually works in testing — where one person is logged in — and leaks one customer's data to another under load.
The annotations
@Override
@Cacheable(value = CacheConfig.MENU_CACHE, keyGenerator = "methodAwareKeyGenerator")
@Transactional(readOnly = true)
public List<ProductDTO> getMenu() {
log.debug("Getting the active menu");
return mapper.mapProductsToProductDTOs(productDAO.findActiveMenu());
}
@Override
@Cacheable(value = CacheConfig.MENU_BY_TYPE_CACHE, key = "#type")
@Transactional(readOnly = true)
public List<ProductDTO> getByType(ProductType type) {
return mapper.mapProductsToProductDTOs(productDAO.findActiveByType(type));
}| Annotation | Does |
|---|---|
@Cacheable | return the cached value if present, otherwise run and store |
@CacheEvict | remove entries |
@CachePut | always run, and update the cache with the result |
@Caching | combine several of the above |
Keys
The default key is built from all the method arguments. key = "#type" names one
explicitly using SpEL — #root.methodName, #result and property paths like
#dto.id are all available.
The no-argument case has a trap the pizza API's key generator exists to fix:
/**
* The default key generator hashes every method argument together. That is reasonable until a
* no-argument method appears: {@code getMenu()} and any other no-argument method on the same
* cache both key to {@link SimpleKey#EMPTY}. Naming the method removes the collision.
*/
@Bean
public KeyGenerator methodAwareKeyGenerator() {
return (target, method, params) ->
params.length == 0 ? method.getName() : method.getName() + "-" + List.of(params);
}Two no-argument methods sharing a cache both key to SimpleKey.EMPTY, so the second one
serves the first one's data. Everything type-checks and the wrong list comes back.
⚠️ Cache names must match exactly. This is why they are constants:
/**
* <p>{@code @Cacheable} and {@code @CacheEvict} refer to a cache by a string. Spelling it
* differently in the two places is the classic caching bug: reads populate {@code "menu"}, writes
* evict {@code "menus"}, and stale data is served forever with nothing failing.
*/Eviction is the hard part
Every write path that could invalidate the cache must evict it. Miss one and the system serves stale data indefinitely — and nothing fails, which is what makes it a support ticket rather than an alert.
The pizza API has five methods that can change the menu, and all five carry the same block:
@Override
@Caching(
evict = {
@CacheEvict(value = CacheConfig.MENU_CACHE, allEntries = true),
@CacheEvict(value = CacheConfig.MENU_BY_TYPE_CACHE, allEntries = true)
})
@Transactional
public ProductDTO updateProduct(UUID id, ProductCreateDTO dto) { /* … */ }Applied to createProduct, updateProduct,
deactivateProduct, deleteProduct and setProductImage. That last
one is easy to forget, and its comment explains the consequence:
/**
* <p>The eviction matters as much as the write. Without it the menu cache keeps serving the
* old image URL and the admin reasonably concludes the upload silently failed.
*/allEntries = true clears the whole cache rather than one key. Here that is correct:
changing one product changes the menu list, and the by-type cache has an entry per type that may
contain it. Evicting precisely would mean working out which entries could reference the product — more
code, more ways to be wrong, for a cache with a handful of entries.
⚠️ The proxy rule applies here too
@Service
public class ProductServiceImpl {
public List<ProductDTO> getMenuTwice() {
getMenu(); // NOT cached — internal call, the proxy is not involved
return getMenu();
}
@Cacheable("menu")
public List<ProductDTO> getMenu() { /* … */ }
}Same mechanism as @Transactional and @Async — lesson 8. A self-invocation
bypasses the proxy, so nothing is cached and nothing tells you.
⚠️ ConcurrentMapCacheManager is a single-JVM cache
/**
* <p>It is an in-memory {@code ConcurrentHashMap}: nothing evicts by time, nothing bounds its size,
* and each instance has its own copy. That is fine for one process and wrong the moment there are
* two — instance A evicts on a write and instance B keeps serving the old menu until it restarts.
*/Three separate limitations, and the third is the one that surprises people in production. With two replicas behind a load balancer, an admin's edit evicts the cache on whichever instance handled the request. The other one carries on serving the old menu.
The fixes:
| Cache | Gives you | Cost |
|---|---|---|
| Caffeine | TTL and size bounds | still per-instance |
| Redis | shared across instances, TTL | infrastructure, network hop |
# Caffeine: bounded and time-limited, still local
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m
# Redis: shared
spring.cache.type=redis
spring.data.redis.host=localhost
spring.cache.redis.time-to-live=600000A TTL is worth having even when your eviction is correct. It bounds how long a missed eviction can hurt you: ten minutes of stale data instead of forever.
Verifying it works
for i in 1 2 3; do curl -s -o /dev/null -w "call $i: %{time_total}s\n" \
http://localhost:8085/api/products; done
# call 1: 0.003512s ← populates the cache
# call 2: 0.001593s
# call 3: 0.001646sBetter still, turn on logging.level.org.springframework.cache=TRACE and watch the hits
and misses directly. Then test the eviction, which is the part that actually breaks: change a product
through the admin API and confirm the menu reflects it immediately.
What to take from this
@EnableCaching, or every annotation is silently inert.- Cache what is read often, shared, and rarely changed. Never per-user data.
- Cache names as constants, and a key generator that includes the method name.
- Every write path evicts. Missing one fails silently and forever.
- The default cache is per-JVM. Add a TTL, and use Redis if you run more than one instance.
Next: Elasticsearch — search a
LIKE query cannot do, and the cost of a second copy of your data.