If you keep hitting the DB or external APIs on every request, response times will gradually creep up. Before committing to a full Redis setup, you might want to try caching at the application layer first. That’s exactly where Spring Cache Abstraction shines.

Add a few annotations and caching starts working immediately — and when you’re ready to swap in Caffeine or Redis, you can switch providers without touching your business logic. This guide walks you through everything you need to know.

What Is Spring Cache Abstraction?

Spring Framework provides caching as an abstraction layer that can be swapped out via DI. You write code using annotations, and the actual data is held by a provider such as ConcurrentHashMap, Caffeine, or Redis. When you want to change providers, your business logic code stays untouched.

Adding the Dependency and @EnableCaching

Start by adding spring-boot-starter-cache.

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

Enable caching by adding @EnableCaching to your application class.

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

That’s all it takes — the default provider (ConcurrentHashMap) is ready to use.

Basic Usage of @Cacheable

A method annotated with @Cacheable is only executed on the first call. The result is stored in the cache, and subsequent calls with the same key return the cached value directly.

@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 value, and you can also access object fields like #user.id.

Note that null is cached by default. For methods that may return null, it is recommended to add unless = "#result == null".

Evicting Cache Entries with @CacheEvict

When data is updated or deleted, you need to remove stale cache entries.

// Remove a specific key from the cache
@CacheEvict(cacheNames = "products", key = "#id")
public void deleteProduct(Long id) {
    productRepository.deleteById(id);
}

// Remove all entries from the cache
@CacheEvict(cacheNames = "products", allEntries = true)
public void clearAll() { ... }

By default, the cache is evicted after the method executes. If you need eviction to happen even when an exception is thrown, use beforeInvocation = true.

Always Updating with @CachePut

@CachePut always executes the method and overwrites the cache with the return value. Unlike @Cacheable, it does not skip the method execution on a cache hit.

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

Use this when you want the cache to reflect the latest data immediately after an entity update.

Common Pitfalls

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

// NG: Calls within the same class bypass the proxy, so caching has no effect
public void process(Long id) {
    this.findById(id); // not cached
}

// NG: Private methods are not subject to AOP proxying
@Cacheable(cacheNames = "products", key = "#id")
private Product findInternal(Long id) { ... } // has no effect

The fix is to structure your code so that calls come from a separate Bean.

Conditional Caching with the condition and unless Attributes

Sometimes you don’t want to cache every call.

// condition: evaluates the arguments to control whether caching applies 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 cache operation, including reads. unless, on the other hand, only skips the write, so existing cache hits are unaffected.

Limitations of the Default Provider (ConcurrentHashMap)

It’s perfectly fine for development and smoke testing, but not suited for production.

  • TTL (expiration) cannot be configured — entries live forever
  • The cache is wiped on application restart
  • Cache cannot be shared across multiple instances

Switch to Caffeine or Redis before going to production.

Switching to Caffeine Cache with TTL

If you need TTL on a single instance, Caffeine is the easiest option. Add the dependency:

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

A single entry in application.properties is all you need.

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

If you need different TTLs for different caches, define them as Beans.

// Caffeine refers to com.github.ben-manes.caffeine.cache.Caffeine (not a 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 Cache 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 via Bean definitions.

@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 over the default JDK serializer, which is hard to read
                    // and prone to compatibility issues on class changes.
                    // 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 Switching Environment-Specific Configuration with Spring Boot Profiles.

Verifying That the Cache Is Working

To confirm via logs, enable the DEBUG level.

logging.level.org.springframework.cache=DEBUG

Log messages containing keywords like found in cache or No cache entry indicate HIT/MISS respectively (exact wording may vary by version and configuration). To verify in tests, 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 constructor

        productService.findById(1L);
        productService.findById(1L); // second call should be served from cache

        // Verify the repository was called only once
        verify(productRepository, times(1)).findById(1L);
    }
}

For testing with Redis, see Integration Testing for Spring Boot with Testcontainers.

Wrapping Up

With Spring Cache Abstraction, you can achieve method-level caching with just a few annotations. Start by verifying behavior with the default provider, switch to Caffeine when you need TTL, and move to Redis when you need a distributed setup. When in doubt, start with Caffeine.

For optimizing DB access itself, check out Spring Boot Data JPA Performance Optimization as well.