Course topics

By WebNest Studio

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 with allEntries = true (after a delete or bulk change).
  • @Caching — combine several operations on one method; @CacheConfig sets 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

Java
@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);
    }
}
Output
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

Java
@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
    }
}
Output
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

Java
<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
Output
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)

Java
<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>
Output
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

Java
@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);
}
Output
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.