Caching with @Cacheable
Skip repeated work by caching method results with @Cacheable, keep entries fresh with @CachePut and @CacheEvict, and avoid the proxy and key traps.
@EnableCaching // on a @Configuration class
@Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
@CachePut(cacheNames = "products", key = "#result.id()")
@CacheEvict(cacheNames = "products", allEntries = true)A cache stores the result of an expensive call so the next identical call returns the stored value instead of doing the work again. Product details, exchange rates and permission lookups are good candidates: they are read far more often than they change, and a slightly old answer is acceptable for a short time.
Spring's cache abstraction lets you add caching with annotations from org.springframework.cache.annotation. Add spring-boot-starter-cache and put @EnableCaching on a configuration class. Without a cache library on the classpath, Spring Boot sets up a simple in-memory cache backed by ConcurrentHashMap maps, which is fine for one instance and for learning.
@Cacheable("products")
public ProductView find(long id) {
return repository.findById(id).map(ProductView::from).orElseThrow();
}The first call with id 42 runs the method and stores the result under key 42 in the products cache. Later calls with 42 return the stored value and the method body never runs.
Keeping the cache honest
@CachePutalways runs the method and writes the result into the cache. Use it on updates so readers see the new value, but not on a@Transactionalmethod: the put can happen before the commit, so a rollback leaves a value in the cache that never reached the database. For transactional updates, evict the entry from a@TransactionalEventListenerthat runs after the commit, as the example below does.@CacheEvictremoves an entry, or every entry withallEntries = true. Use it on deletes and on changes you cannot easily re-read. On a@Transactionalmethod it has the same timing problem: a reader can put the old row back before the commit, so the example evicts after the commit for deletes too.keytakes a SpEL expression such as#idor#request.sku(). The default key is built from all parameters, so an extra parameter like a locale silently becomes part of the key.unless = "#result == null"skips storing a result;conditiondecides before the call whether caching applies at all.sync = truelets only one thread compute a missing entry while the others wait, which stops a stampede when a popular entry expires. It cannot be combined withunless.
Mistakes that bite
Caching works through a proxy, exactly like @Transactional. A call from one method to another in the same class bypasses it, so nothing is cached. Annotate the method that other beans call.
Cache DTOs, not JPA entities. A cached entity is detached, its lazy collections throw when touched, and a caller that changes it changes the cached copy every other request sees. Return an immutable record instead.
The simple cache never expires and never limits its size. In production use a provider with time-to-live and a maximum size, such as Redis or Caffeine, or the cache grows until the heap runs out. Also remember that each instance of the simple cache is local: with three replicas behind a load balancer, an eviction on one replica leaves the other two serving the old value.
Decide the staleness you can accept before you add a cache. If the answer is none, do not cache.
Example
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.math.BigDecimal;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
class CacheConfig {
}
class Product {
private Long id;
private String name;
private BigDecimal price;
protected Product() {
}
Long getId() { return id; }
String getName() { return name; }
BigDecimal getPrice() { return price; }
void changePrice(BigDecimal price) { this.price = price; }
}
interface ProductRepository extends JpaRepository<Product, Long> {
}
record ProductView(long id, String name, BigDecimal price) {
static ProductView from(Product p) {
return new ProductView(p.getId(), p.getName(), p.getPrice());
}
}
class ProductNotFoundException extends RuntimeException {
ProductNotFoundException(long id) {
super("Product " + id + " not found");
}
}
record ProductChanged(long id) {
}
class ProductCacheEvictor {
(cacheNames = "products", key = "#event.id()")
void onProductChanged(ProductChanged event) {
}
}
public class ProductService {
private final ProductRepository repository;
private final ApplicationEventPublisher events;
public ProductService(ProductRepository repository, ApplicationEventPublisher events) {
this.repository = repository;
this.events = events;
}
(cacheNames = "products", key = "#id")
(readOnly = true)
public ProductView find(long id) {
return repository.findById(id)
.map(ProductView::from)
.orElseThrow(() -> new ProductNotFoundException(id));
}
public ProductView changePrice(long id, BigDecimal price) {
Product product = repository.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
product.changePrice(price);
events.publishEvent(new ProductChanged(id));
return ProductView.from(product);
}
public void delete(long id) {
repository.deleteById(id);
events.publishEvent(new ProductChanged(id));
}
}
When to use it
- A product catalogue API caches product details by id and evicts the entry after the transaction commits whenever a price changes or a product is deleted.
- A checkout service caches currency exchange rates from a slow remote provider for a few minutes with sync = true so a burst of orders makes one remote call.
- An admin API caches the list of feature flags and clears the whole cache with @CacheEvict(allEntries = true) when an administrator saves a change.
More examples
Conditions, unless and sync
The key is a readable string such as GBP:EUR rather than an object, which makes entries easy to find in a shared cache. The condition skips the cache for the trivial same-currency case, and sync = true makes one thread call the remote provider while concurrent callers for the same pair wait for its answer. sync = true does not allow unless, so filter bad results inside the method or let the provider throw instead of returning null.
import java.math.BigDecimal;
import java.util.Currency;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
record Quote(String from, String to, BigDecimal rate) {
}
interface RateProvider {
Quote fetch(String from, String to);
}
@Service
public class ExchangeRateService {
private final RateProvider provider;
public ExchangeRateService(RateProvider provider) {
this.provider = provider;
}
@Cacheable(cacheNames = "rates",
key = "#from.currencyCode + ':' + #to.currencyCode",
condition = "!#from.equals(#to)",
sync = true)
public Quote rate(Currency from, Currency to) {
if (from.equals(to)) {
return new Quote(from.getCurrencyCode(), to.getCurrencyCode(), BigDecimal.ONE);
}
return provider.fetch(from.getCurrencyCode(), to.getCurrencyCode());
}
}
Naming caches and turning caching off in tests
spring.cache.cache-names creates the listed caches at startup, and with the simple provider that also stops caches being created on demand, so a typo in a cache name fails loudly instead of silently making a new cache. Setting spring.cache.type=none in a test profile keeps the annotations but caches nothing, which helps when a test expects fresh data on every call.
# application.properties
spring.cache.cache-names=products,rates
spring.cache.type=simple
# application-test.properties: every @Cacheable method runs its body
# spring.cache.type=none
Proving the cache works
A small Spring test context with only caching and one bean runs in milliseconds. Calling the method twice and counting real lookups proves the proxy is in place and the key is what you expect, which is exactly what breaks when someone adds a parameter or moves the call inside the same class.
import static org.assertj.core.api.Assertions.assertThat;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@SpringJUnitConfig
class CountryServiceCacheTest {
static class CountryService {
final AtomicInteger lookups = new AtomicInteger();
@Cacheable("countries")
public String name(String code) {
lookups.incrementAndGet();
return code.equals("GB") ? "United Kingdom" : "Unknown";
}
}
@Configuration
@EnableCaching
static class Config {
@Bean
CacheManager cacheManager() {
return new ConcurrentMapCacheManager("countries");
}
@Bean
CountryService countryService() {
return new CountryService();
}
}
@Autowired
CountryService service;
@Test
void secondCallIsServedFromTheCache() {
assertThat(service.name("GB")).isEqualTo("United Kingdom");
assertThat(service.name("GB")).isEqualTo("United Kingdom");
assertThat(service.lookups.get()).isEqualTo(1);
}
}
Discussion