Here is the English translation of the article body.

If you hit the database or an external API on every identical request, your response times gradually get worse. Before going all in on Redis, you may want to try caching on the application side first. That is exactly where Spring Cache Abstraction comes in handy.

Adding a few annotations is enough to get caching working, and you can later swap in Caffeine or Redis without changing your code. This article walks through how to use it from start to finish.

What Is Spring Cache Abstraction?

Spring Framework provides caching as an abstraction layer that can be swapped out via DI. You write your code using annotations, while the actual data is held by a provider such as ConcurrentHashMap, Caffeine, or Redis. Even if you decide to change providers later, your business logic code stays exactly as it is.

Adding the Dependency and @EnableCaching

First, add spring-boot-starter-cache.

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-cache'
}

Annotate your application class with @EnableCaching to turn caching on.

@SpringBootApplication
@EnableCaching
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

That alone lets you use the default provider (ConcurrentHashMap).

Basic Usage of @Cacheable

A method annotated with @Cacheable is executed only on the first call, and its result is stored in the cache. Subsequent calls with the same key return the value from the cache.

@Service
public class ProductService {

    @Cacheable(cacheNames = "products", key = "#id")
    public Product findById(Long id) {
        return productRepository.findById(id).orElseThrow();
    }

    @Cacheable(cacheNames = "products", key = "#category + '-' + #page")
    public List<Product> findByCategory(String category, int page) {
        return productRepository.findByCategory(category, page);
    }
}

The key attribute uses SpEL. #id refers to the argument’s value, and you can also reference object fields, such as #user.id.

Note that null values are cached by default. For methods that may return null, we recommend adding unless = "#result == null".

Removing Cache Entries with @CacheEvict

When you update or delete data, you need to remove the stale cache entries.

// 特定キーのキャッシュを削除
@CacheEvict(cacheNames = "products", key = "#id")
public void deleteProduct(Long id) {
    productRepository.deleteById(id);
}

// キャッシュ全体を削除
@CacheEvict(cacheNames = "products", allEntries = true)
public void clearAll() { ... }

By default, the cache entry is removed after the method executes. If you want the entry to be removed even when an exception is thrown, specify beforeInvocation = true.

Always Updating with @CachePut

@CachePut always executes the method and overwrites the cache with its return value. The difference from @Cacheable is that the method is not skipped even on a cache hit.

@CachePut(cacheNames = "products", key = "#product.id")
public Product updateProduct(Product product) {
    return productRepository.save(product);
}

Use it when you want the latest data reflected in the cache immediately after updating an entity.

Common Pitfalls

Because Spring Cache is based on AOP proxies, caching does not work in the following cases.

// NG: 同一クラス内からの呼び出しはプロキシをバイパスするためキャッシュが効かない
public void process(Long id) {
    this.findById(id); // キャッシュされない
}

// NG: privateメソッドはAOPプロキシの対象外
@Cacheable(cacheNames = "products", key = "#id")
private Product findInternal(Long id) { ... } // 効かない

The fix is to extract the method into a separate Bean and call it from there. If you absolutely must keep everything within the same class, you can call through AopContext.currentProxy(), but this hurts readability, so we do not recommend it for regular use.

Conditional Caching with the condition and unless Attributes

Sometimes you cannot cache every single call.

// condition: 引数を評価してキャッシュ処理自体を制御する
@Cacheable(cacheNames = "products", key = "#category", condition = "#page == 0")
public List<Product> findByCategory(String category, int page) { ... }

// unless: 戻り値を評価してキャッシュへの書き込みをスキップする
@Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
public Product findById(Long id) { ... }

condition disables the entire caching process, including cache reads. unless, on the other hand, only skips the write, so it does not affect existing cache hits.

Limitations of the Default Provider (ConcurrentHashMap)

It is sufficient for development and quick verification, but not suited for production use.

  • You cannot set a TTL (expiration), so entries persist forever
  • The cache is reset whenever the application restarts
  • The cache cannot be shared across multiple instances

Switch to Caffeine or Redis before deploying to production.

Choosing Between Caffeine and Redis

Which provider to choose depends on your operational requirements. Use the table below as a guide.

AspectConcurrentHashMapCaffeineRedis
TTLNoYesYes
Size limitNoYes (W-TinyLFU)Yes (maxmemory-policy)
Sharing across processesNoNoYes
PersistenceNoNoYes (RDB/AOF)
Additional infrastructureNoneNoneRequires a Redis server
Expected throughputLocal onlyMillions of ops/sBounded by network round trips
Typical useDevelopment / PoCSingle-instance productionMulti-instance / distributed environments

If in doubt, the safe approach is to start with Caffeine and switch to Redis once you need to scale horizontally. Thanks to Spring Cache Abstraction, your code needs almost no changes.

Measuring Cache Hit Rate with Micrometer

To quantify how effective your cache is, the reliable approach is to collect metrics via Micrometer. Caffeine registers itself with Micrometer automatically when recordStats() is enabled.

@Bean
public CacheManager cacheManager(MeterRegistry registry) {
    CaffeineCacheManager manager = new CaffeineCacheManager();
    manager.setCaffeine(Caffeine.newBuilder()
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .maximumSize(1000)
        .recordStats()); // 統計収集を有効化
    return manager;
}

If Actuator is enabled, you can check the hit count at /actuator/metrics/cache.gets?tag=result:hit and the miss count with tag=result:miss. If you export to Prometheus, you can visualize the hit rate with the query rate(cache_gets_total{result="hit"}[5m]) / rate(cache_gets_total[5m]).

A hit rate that is too low (below roughly 60% as a rule of thumb) is a sign that you should revisit your key granularity, TTL, and cache size.

Switching to Caffeine with TTL

If you want to set a TTL on a single instance, Caffeine is the easy option. Add the dependency.

implementation 'com.github.ben-manes.caffeine:caffeine'

It works with just a few lines in application.properties.

spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=10m

If you want different TTLs for multiple caches, define a Bean.

// Caffeine は com.github.ben-manes.caffeine.cache.Caffeine(Spring側のクラスとは別)
@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.registerCustomCache("products",
            Caffeine.newBuilder().expireAfterWrite(10, TimeUnit.MINUTES).maximumSize(500).build());
        manager.registerCustomCache("categories",
            Caffeine.newBuilder().expireAfterWrite(60, TimeUnit.MINUTES).maximumSize(100).build());
        return manager;
    }
}

Switching to Redis with TTL

If you need to share the cache across multiple instances, use Redis.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
spring.data.redis.host=localhost
spring.data.redis.port=6379

Configure the TTL and serializer in a Bean definition.

@Configuration
public class RedisCacheConfig {

    @Bean
    // 複数CacheManager Beanがある場合は@Primaryを付与
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    // デフォルトのJDKシリアライザは可読性が低くクラス変更時に互換問題が起きやすいため推奨
                    // ※クラス名がJSONに埋め込まれるためクラスのリネーム時は注意
                    new GenericJackson2JsonRedisSerializer()
                )
            );

        return RedisCacheManager.builder(factory)
            .cacheDefaults(config)
            .build();
    }
}

If you want to switch providers per profile, see also How to Switch Configuration per Environment with Spring Boot Profiles.

How to Verify That Caching Is Working

To check via logs, enable the DEBUG level.

logging.level.org.springframework.cache=DEBUG

If you see log lines containing keywords such as found in cache or No cache entry, you can tell whether it was a HIT or a MISS (the exact wording varies by version and configuration). If you would rather verify in a test, you can write something like this.

@SpringBootTest
class ProductServiceCacheTest {

    @Autowired ProductService productService;
    @MockBean ProductRepository productRepository;

    @Test
    void キャッシュが効くこと() {
        when(productRepository.findById(1L))
            .thenReturn(Optional.of(new Product(1L, "テスト商品"))); // お使いのProductエンティティのコンストラクタに合わせて変更してください

        productService.findById(1L);
        productService.findById(1L); // 2回目はキャッシュから返るはず

        // リポジトリは1回しか呼ばれていないことを検証
        verify(productRepository, times(1)).findById(1L);
    }
}

For tests that use Redis, see also Integration Testing in Spring Boot with Testcontainers.

Summary

With Spring Cache Abstraction, you can implement method-level caching with just a few annotations. Start by verifying the behavior with the default provider, then switch to Caffeine when you need a TTL and to Redis when you move to a distributed environment. If in doubt, try Caffeine first.

For optimizing database access itself, reading Spring Boot Data JPA Performance Optimization and How to Properly Configure and Tune the HikariCP Connection Pool in Spring Boot alongside this article will broaden your toolkit for improving response times. If you want to combine caching with asynchronous processing, see How to Decouple Modules with ApplicationEvent in Spring Boot, and for Controller-layer testing strategies, see How to Write Controller Unit Tests with MockMvc in Spring Boot.