Hitting the DB or external APIs on every identical request gradually slows down your response times. Before going all-in on Redis, you might want to try caching at the application layer first. That’s where Spring Cache Abstraction comes in handy.
Add a few annotations and caching starts working. Later, you can swap in Caffeine or Redis without changing your code. Let me walk through how to use it.
What is Spring Cache Abstraction?
Spring Framework provides caching as an abstraction layer that’s swappable via DI. You write annotation-based code, and the actual data is held by providers like ConcurrentHashMap, Caffeine, or Redis. Even when you want to change providers, your business logic code stays the same.
Adding Dependencies and @EnableCaching
First, add spring-boot-starter-cache.
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-cache'
}
Add @EnableCaching to your application class to enable it.
@SpringBootApplication
@EnableCaching
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
This alone gives you the default provider (ConcurrentHashMap).
Basic Usage of @Cacheable
A method annotated with @Cacheable runs only on the first call and stores its result 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 references the argument value, and you can also reference object fields like #user.id.
Note that null values are cached by default. For methods that might return null, it’s recommended to add unless = "#result == null".
Removing Cache Entries with @CacheEvict
When you update or delete data, you need to remove the stale cache entries.
// Remove cache for a specific key
@CacheEvict(cacheNames = "products", key = "#id")
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
// Remove the entire cache
@CacheEvict(cacheNames = "products", allEntries = true)
public void clearAll() { ... }
By default, the cache is evicted after the method executes. If you want eviction to happen even when an exception is thrown, specify beforeInvocation = true.
Always Updating with @CachePut
@CachePut always executes the method and overwrites the cache with the return value. The difference from @Cacheable is that it doesn’t skip the method even on a cache HIT.
@CachePut(cacheNames = "products", key = "#product.id")
public Product updateProduct(Product product) {
return productRepository.save(product);
}
Use this when you want to immediately reflect the latest data in the cache after updating an entity.
Common Pitfalls
Since Spring Cache is based on AOP proxies, caching won’t work in the following cases.
// NG: calls from within the same class bypass the proxy, so caching doesn't work
public void process(Long id) {
this.findById(id); // not cached
}
// NG: private methods are not subject to AOP proxies
@Cacheable(cacheNames = "products", key = "#id")
private Product findInternal(Long id) { ... } // doesn't work
The workaround is to structure your code so the call comes from a different Bean.
Conditional Caching with condition and unless
There are cases where you don’t want to cache every call.
// condition: evaluates arguments to control whether caching happens at all
@Cacheable(cacheNames = "products", key = "#category", condition = "#page == 0")
public List<Product> findByCategory(String category, int page) { ... }
// unless: evaluates the return value to skip writing to the cache
@Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
public Product findById(Long id) { ... }
condition disables the entire caching process, including reads. unless, on the other hand, only skips writes, so it doesn’t affect existing cache HITs.
Limitations of the Default Provider (ConcurrentHashMap)
Choosing Between Caffeine and Redis
Which provider to choose depends on your operational requirements. Use the table below as a decision guide.
| Aspect | ConcurrentHashMap | Caffeine | Redis |
|---|---|---|---|
| TTL | No | Yes | Yes |
| Size limit | No | Yes (W-TinyLFU) | Yes (maxmemory-policy) |
| Inter-process sharing | No | No | Yes |
| Persistence | No | No | Yes (RDB/AOF) |
| Additional infrastructure | None | None | Redis server required |
| Expected throughput | Local only | Millions of ops/s | Limited by network round-trip |
| Main use case | Development / PoC | Single-instance production | Multiple instances / distributed environments |
If you’re unsure, the safe path is start with Caffeine and switch to Redis when you need horizontal scaling. Thanks to Spring Cache’s abstraction, your code requires almost no changes.
Measuring Cache Hit Rate with Micrometer
To quantitatively assess cache effectiveness, the reliable approach is to gather metrics via Micrometer. Caffeine automatically registers with Micrometer when you enable recordStats().
@Bean
public CacheManager cacheManager(MeterRegistry registry) {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000)
.recordStats()); // Enable stats collection
return manager;
}
With Actuator enabled, you can check HIT counts at /actuator/metrics/cache.gets?tag=result:hit and MISS counts at 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]).
If your HIT rate is too low (a rough guideline is below 60%), it’s a sign that you need to revisit your key design granularity, TTL, or cache size.
It’s fine for development and verification, but not suitable for production use.
- TTL (expiration) cannot be set, so entries persist indefinitely
- The cache is reset when the application restarts
- The cache cannot be shared across multiple instances
Before going to production, switch to Caffeine or Redis.
Switching to Caffeine with TTL
If you want TTL on a single instance, Caffeine is the easy option. Add the dependency.
implementation 'com.github.ben-manes.caffeine:caffeine'
It works just by adding settings to application.properties.
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=10m
If you want to set different TTLs for multiple caches, define it as a Bean.
// Caffeine here is com.github.ben-manes.caffeine.cache.Caffeine (different from the Spring class)
@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
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 TTL and serializers in a Bean definition.
@Configuration
public class RedisCacheConfig {
@Bean
// Add @Primary if you have multiple CacheManager Beans
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
// Recommended because the default JDK serializer has poor readability
// and tends to break compatibility when classes change
// Note: class names are embedded in the JSON, so be careful when renaming classes
new GenericJackson2JsonRedisSerializer()
)
);
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
If you want to switch providers per profile, see How to Switch Configuration per Environment Using Spring Boot Profiles for reference.
How to Verify the Cache is Working
To check via logs, enable the DEBUG level.
logging.level.org.springframework.cache=DEBUG
You can identify HIT/MISS from log messages containing keywords like found in cache or No cache entry (the exact wording varies by version and configuration). If you want to verify with a test, you can write something like this.
@SpringBootTest
class ProductServiceCacheTest {
@Autowired ProductService productService;
@MockBean ProductRepository productRepository;
@Test
void cacheShouldWork() {
when(productRepository.findById(1L))
.thenReturn(Optional.of(new Product(1L, "Test Product"))); // Adjust to match your Product entity's constructor
productService.findById(1L);
productService.findById(1L); // The second call should return from the cache
// Verify the repository is only called once
verify(productRepository, times(1)).findById(1L);
}
}
For Redis-based testing, see Integration Testing Spring Boot with Testcontainers.
Summary
With Spring Cache Abstraction, you can achieve per-method caching with just a few annotations. Start by verifying behavior with the default provider, then switch to Caffeine when you need TTL, and to Redis when you move to a distributed environment. When in doubt, try Caffeine first.
For optimizing DB access itself, reading Spring Boot Data JPA Performance Optimization and How to Properly Configure and Tune the Spring Boot HikariCP Connection Pool alongside this will broaden your toolkit for improving response times. If you’re combining with asynchronous processing, see How to Use Spring Boot ApplicationEvent for Loose Coupling Between Modules, and for Controller-layer testing strategies, see How to Write Unit Tests for Spring Boot Controllers Using MockMvc.
Frequently Asked Questions (FAQ)
What is Spring Cache?
Spring Cache is a caching abstraction layer provided by Spring Framework. Just by adding annotations like @Cacheable to your methods, caching takes effect, and the underlying provider (ConcurrentHashMap / Caffeine / Redis, etc.) can be swapped without changing your business logic.
What’s the difference between @Cacheable and @CachePut?
@Cacheable skips the method and returns the value when there’s a cache HIT. @CachePut always executes the method and overwrites the cache with the return value. Use @Cacheable for read-heavy scenarios and @CachePut when you want to reflect the latest data on updates.
When is @CacheEvict invoked?
By default, the cache is evicted after the method completes successfully. If you want eviction to happen even when an exception occurs, specify beforeInvocation = true.
Why doesn’t @Cacheable work when calling a method in the same class?
Spring Cache is implemented with AOP proxies, so self-invocations like this.method() bypass the proxy. You need to either extract the method into a separate Bean or use AopContext.currentProxy().
Should I choose Caffeine or Redis?
If everything runs on a single instance and you only need TTL and size limits, Caffeine is the easier choice. Choose Redis when you need to share the cache across multiple instances or retain data after restarts. The standard approach is to verify behavior with Caffeine first, then switch to Redis when scaling out becomes necessary.