Spring Boot Tutorial
Caching with the Spring Cache Abstraction
Some data is expensive to compute or fetch and changes rarely: the course catalogue, exchange rates, a user's permissions, a third-party API response. Fetching it on every request wastes database time and money. A cache keeps recent results in fast storage so repeated requests are answered instantly.
Spring's cache abstraction lets you add caching with annotations — @Cacheable, @CachePut, @CacheEvict — independent of the cache technology. This lesson covers enabling caching, cache keys and conditions, keeping caches up to date, choosing and configuring providers (Caffeine locally, Redis for distributed caching), expiry, and common caching pitfalls.
Enabling Caching
Add @EnableCaching to a configuration class and spring-boot-starter-cache. Without any provider, Spring Boot uses a simple ConcurrentHashMap-based cache — fine for demos but without expiry or size limits. Add Caffeine for a high-performance local cache, EhCache (through the JCache/JSR-107 API) when you need off-heap or disk tiers, or Redis for a cache shared by all instances. Spring Boot detects the provider and configures a CacheManager.
The Annotations
Caching is applied through proxies, like transactions:
@Cacheable("courses")— return the cached value if present; otherwise run the method and store the result.@CachePut— always run the method and update the cache with the result (after an update).@CacheEvict— remove an entry, or all entries withallEntries = true(after a delete or bulk change).@Caching— combine several operations on one method;@CacheConfigsets defaults for a class.
Keys and Conditions
The default key is built from all method parameters. Use SpEL to choose: key = "#slug", key = "#user.id", or a custom KeyGenerator. condition decides whether to use the cache at all (checked before the call) and unless vetoes storing a result (checked after, e.g. unless = "#result == null"). sync = true ensures only one thread computes a missing value while others wait — protecting against a stampede on a popular key.
Expiry and Consistency
Every cache trades freshness for speed. Decide per cache how stale data may be and set a time-to-live accordingly. Evict or update entries when your application changes the underlying data. If other systems also modify the data, a short TTL is your safety net. Remember that cached objects must be serialisable for distributed caches, and that the same caching proxy rules apply as for @Transactional: self-invocation bypasses the cache.
Examples
Caching a slow lookup with @Cacheable
@Configuration
@EnableCaching
public class CacheConfig {}
@Service
public class CourseCatalogService {
private static final Logger log = LoggerFactory.getLogger(CourseCatalogService.class);
private final CourseRepository courses;
public CourseCatalogService(CourseRepository courses) {
this.courses = courses;
}
@Cacheable(cacheNames = "courseBySlug", key = "#slug", unless = "#result == null")
public CourseDto findBySlug(String slug) {
log.info("Loading course {} from the database", slug);
return courses.findBySlug(slug).map(CourseDto::from).orElse(null);
}
}
GET /api/courses/spring-boot -> INFO Loading course spring-boot from the database (35 ms)
GET /api/courses/spring-boot -> (no log, served from cache) (1 ms)
GET /api/courses/java-core -> INFO Loading course java-core from the database
Keeping the cache consistent with @CachePut and @CacheEvict
@Service
@CacheConfig(cacheNames = "courseBySlug")
public class CourseAdminService {
private final CourseRepository courses;
public CourseAdminService(CourseRepository courses) {
this.courses = courses;
}
@CachePut(key = "#result.slug()")
@Transactional
public CourseDto update(String slug, UpdateCourseRequest req) {
Course c = courses.findBySlug(slug).orElseThrow();
c.setTitle(req.title());
c.setPrice(req.price());
return CourseDto.from(c);
}
@CacheEvict(key = "#slug")
@Transactional
public void delete(String slug) {
courses.deleteBySlug(slug);
}
@CacheEvict(allEntries = true)
public void reloadCatalog() {
// called after a bulk import
}
}
update("spring-boot", {title:"Spring Boot 4", ...}) -> cache entry "spring-boot" replaced with new title
delete("html") -> cache entry "html" removed
reloadCatalog() -> all "courseBySlug" entries removed
Caffeine as a local cache with size limits and expiry
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
# application.yml
spring:
cache:
cache-names: courseBySlug, exchangeRates
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m,recordStats
management:
endpoints:
web:
exposure:
include: health, caches, metrics
GET /actuator/caches
{"cacheManagers":{"cacheManager":{"caches":{"courseBySlug":{"target":"com.github.benmanes.caffeine.cache.BoundedLocalCache$..."},"exchangeRates":{...}}}}}
GET /actuator/metrics/cache.gets?tag=name:courseBySlug&tag=result:hit
{"measurements":[{"statistic":"COUNT","value":1842.0}]}
EhCache 3 as the provider through JCache (JSR-107)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>javax.cache</groupId>
<artifactId>cache-api</artifactId>
</dependency>
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<classifier>jakarta</classifier>
</dependency>
# application.yml
spring:
cache:
jcache:
config: classpath:ehcache.xml
<!-- src/main/resources/ehcache.xml -->
<config xmlns="http://www.ehcache.org/v3">
<cache alias="courseBySlug">
<key-type>java.lang.String</key-type>
<value-type>com.webnest.shop.course.CourseDto</value-type>
<expiry><ttl unit="minutes">30</ttl></expiry>
<resources>
<heap unit="entries">2000</heap>
<offheap unit="MB">32</offheap> <!-- EhCache can also use off-heap memory and disk -->
</resources>
</cache>
</config>
Cache manager: JCacheCacheManager (org.ehcache.jsr107.EhcacheCachingProvider)
(The same @Cacheable code now uses EhCache — only dependencies and configuration changed.)
Preventing a cache stampede with sync = true
@Cacheable(cacheNames = "exchangeRates", key = "#base", sync = true)
public Map<String, BigDecimal> ratesFor(String base) {
// slow external API call: only ONE thread per key executes it at a time
return ratesClient.fetch(base);
}
100 concurrent requests for "INR" after the entry expires
-> 1 call to the external rates API, 99 requests wait and reuse the result
Common Mistakes
- Caching without any expiry or size limit, eventually exhausting memory with the default ConcurrentHashMap cache.
- Forgetting to evict or update the cache when data changes, serving stale data indefinitely.
- Calling a @Cacheable method from the same class, bypassing the proxy so the cache is never used.
- Caching mutable objects and then modifying them, silently changing the cached value for everyone.
- Using a local cache in a multi-instance deployment where every instance may hold different values; use Redis or short TTLs.
Key Points to Remember
- @EnableCaching plus @Cacheable, @CachePut and @CacheEvict add caching declaratively.
- Control keys with SpEL; use condition, unless and sync for fine control.
- Caffeine is an excellent local cache; Redis provides a shared distributed cache.
- Always define expiry and a strategy for keeping cached data consistent.
- Monitor hit rates via the caches and metrics Actuator endpoints.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.