Hello! Welcome back to our module on "Advanced Concurrency & Performance."
In our last lesson, we explored how to build highly scalable services using Spring WebFlux and reactive programming. By adopting a non-blocking model, we saw how to handle high I/O workloads efficiently. Today, we'll tackle performance from a different, yet equally critical, angle: caching.
While reactive programming optimizes how we handle requests, caching optimizes how we fetch the data for those requests. Its goal is to dramatically reduce latency and decrease the load on your downstream systems, such as databases or other microservices. This is a fundamental technique for building high-performance, resilient systems and a very common topic in senior engineering interviews.
Our learning outcome for this lesson is to implement the cache-aside pattern using Redis with Spring caching annotations (@Cacheable, @CacheEvict).
1. The Cache-Aside Pattern
The most common caching strategy you'll encounter is cache-aside, also known as lazy loading. The logic is straightforward: your application code manages the interaction with both the cache and the primary data store (e.g., a database).
Here is the flow:
- When your application needs data, it first checks the cache.
- Cache Hit: If the data is found in the cache, it's returned directly to the application. The database is never queried.
- Cache Miss: If the data is not in the cache, the application queries the database to retrieve it.
- The application then stores (writes) this data into the cache so that the next request for the same data results in a cache hit.
- Finally, the data is returned to the application.

To see a quick walkthrough of this pattern, watch the first couple of minutes of the following video.
REST API Caching Strategies Every Developer Must Know
This video from ByteMonk explains the fundamentals of application-layer caching and provides a clear, conceptual demonstration of the cache-aside pattern with Redis.
Watch from the beginning until 02:14. Focus on how the code handles a cache miss: it fetches from the database and then writes the result back to the cache.
The primary advantage of cache-aside is its resilience. If your cache fails, the application can still function by fetching everything from the database, albeit more slowly. The main challenge is cache invalidation—ensuring that when data changes in the database, the corresponding entry in thecache is removed or updated to avoid serving stale data.
2. Implementing Caching with Spring Boot and Redis
Manually writing cache-check, database-fetch, and cache-write logic can be repetitive and error-prone. Fortunately, Spring Boot provides a powerful abstraction layer that allows you to implement this pattern declaratively with a few simple annotations.

Let's walk through the setup and implementation.
Step 1: Dependencies and Configuration
First, you need to add the Spring Data Redis dependency to your pom.xml.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Next, configure your application.yml (or application.properties) to tell Spring to use Redis as its cache provider and specify the connection details.
spring:
cache:
type: redis # Tells Spring to use Redis for caching
redis:
host: localhost
port: 6379
Finally, you need to enable caching in your application by adding the @EnableCaching annotation to your main Spring Boot class.
@SpringBootApplication
@EnableCaching // Triggers Spring's caching infrastructure
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
Step 2: Using Caching Annotations
With the configuration in place, you can now use annotations on your service-layer methods to control caching behavior.
@Cacheable: Implements the cache-aside pattern. Before executing the method, Spring checks the cache. If an entry is found, it's returned immediately, and the method is skipped. If not, the method is executed, and its return value is stored in the cache.@CacheEvict: Used for cache invalidation. This annotation removes an entry from the cache. You typically use it on methods that update or delete data.
Let's apply this to a simple UserService. Assume we have a method to find a user by ID and another to delete a user.
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
// The result of this method will be cached.
// 'users' is the cache name.
// '#id' refers to the 'id' parameter of the method, used as the cache key.
@Cacheable(value = "users", key = "#id")
public User findUserById(Long id) {
// This log will only appear on a cache miss.
System.out.println("Fetching user from database for ID: " + id);
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found"));
}
// This method will remove an entry from the 'users' cache.
// The key is determined by the 'id' parameter.
@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
System.out.println("Deleting user and evicting from cache for ID: " + id);
userRepository.deleteById(id);
}
}
With @Cacheable, the first call to findUserById(1L) will execute the method, hit the database, and store the returned User object in the "users" cache with a key of 1. Subsequent calls to findUserById(1L) will return the cached object directly without ever executing the method body.
When deleteUser(1L) is called, the @CacheEvict annotation ensures the entry for key 1 is removed from the "users" cache. The next call to findUserById(1L) will result in a cache miss, forcing a fresh read from the database.
For a deeper look into the annotations and a complete project example, the following guide is an excellent resource.
Spring Boot caching with Redis
The article "Spring Boot caching with Redis" provides a concise, step-by-step guide to setting up and using Spring's caching annotations.
Please read 'Step 4: Enable caching in spring boot' and 'Step 5: Annotation-based caching on the controller layer'. The article applies annotations at the controller level for simplicity, but in production applications, it's a best practice to apply them at the service layer as we did above. Focus on the explanation of each annotation: @Cacheable, @CachePut, @CacheEvict, and @Caching.
Step 3: Configuring Time-To-Live (TTL)
A crucial aspect of caching is managing data freshness. You don't want cached items to live forever, as they might become stale. Time-To-Live (TTL) is a setting that automatically evicts a cache entry after a certain duration.
You can set a global default TTL in your application.yml:
spring:
cache:
type: redis
redis:
time-to-live: 600000 # 10 minutes in milliseconds
However, in a real-world application, different types of data have different volatility. You might want user profiles to be cached for 10 minutes but product prices for only 1 minute. Spring allows you to configure different TTLs for different cache names (users, products, etc.) by defining a RedisCacheManager bean.
The following guide demonstrates how to set up multiple cache regions with different TTLs.
Spring Boot Redis Multi-Cache: A Complete Guide
The article "Spring Boot Redis Multi-Cache: A Complete Guide" explains how to build a more sophisticated, production-oriented caching strategy.
Skim through this guide to see a more complete implementation. Pay attention to: Section '6️⃣ Application Configuration': Shows the basic Redis properties. Section '8️⃣ Testing the Cache Implementation': The 'Cache Behavior Matrix' is an excellent summary of how different endpoints interact with the cache using various annotations. The guide mentions a RedisConfig.java file (inferred from the Project Structure section), which is where you would typically define per-cache TTLs. Although the code for that specific file isn't shown, understanding its purpose is key.
Here's an example of how you would define that RedisConfig.java to set different TTLs:
@Configuration
public class RedisConfig {
@Bean
public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
return (builder) -> builder
.withCacheConfiguration("users", // Cache name
RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(10)))
.withCacheConfiguration("products", // Another cache name
RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(5)));
}
}
This configuration sets a 10-minute TTL for the users cache and a 5-minute TTL for the products cache, giving you fine-grained control.
Practice for Your Interview
Let's test your understanding. This kind of question assesses your ability to apply patterns to solve a practical problem.
Test your understanding!
You are working on a ProductService for an e-commerce platform. The service has two methods:
Product getProductDetails(Long productId): Fetches product details from the database. This is a very frequent read operation.Product updateProduct(Product product): Updates product information in the database.
Your task is to add caching to improve performance. The product data doesn't change very often, so it can be cached for several minutes.
How would you annotate these two methods to implement an efficient caching strategy? Specify the annotation, cache name ("products"), and key for each method.
Show answer
Here is the recommended solution:
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
@Cacheable(value = "products", key = "#productId")
public Product getProductDetails(Long productId) {
// Fetches from DB and populates cache on miss
return productRepository.findById(productId).orElse(null);
}
@CachePut(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
// Updates the DB
Product updatedProduct = productRepository.save(product);
// The @CachePut annotation ensures the return value (the updated product)
// replaces the old entry in the cache.
return updatedProduct;
}
}
Explanation:
-
getProductDetailsuses@Cacheable. This implements the cache-aside pattern for reads. If the product is in the"products"cache under the keyproductId, it's returned instantly. Otherwise, the database is queried, and the result is cached. -
updateProductuses@CachePut. This is often a better choice for updates than@CacheEvict. While@CacheEvictwould simply remove the old entry (causing a cache miss on the next read),@CachePutupdates the cache directly with the new state of the object returned by the method. This keeps the cache "warm" and avoids a database hit on the next read. The key is derived from theidfield of the incomingProductobject.
Conclusion
Today, we explored caching as a powerful tool for building high-performance microservices. You learned how to implement the most common caching strategy, cache-aside, using Spring Boot's declarative annotations, which abstract away the complexity of interacting with a cache like Redis.
Key Takeaways:
- Cache-Aside Pattern: A lazy-loading strategy where the application is responsible for fetching data from the database on a cache miss and populating the cache.
- Spring Caching Abstraction: Use
@EnableCachingto activate it, and then apply annotations like@Cacheable,@CacheEvict, and@CachePutto your service methods. @Cacheableis for read operations, implementing the core cache-aside logic.@CacheEvictand@CachePutare for write operations, ensuring the cache stays consistent with the database.- TTL (Time-To-Live) is essential for automatically evicting stale data and can be configured globally or per cache region for fine-grained control.
Next Up
The cache-aside pattern is excellent for read-heavy workloads, but it's not the only strategy. In our next lesson, we will explore advanced caching patterns, including write-through, write-behind, and eviction policies. Understanding these different patterns and their trade-offs is crucial for designing sophisticated, production-ready systems and will further strengthen your position in senior-level interviews.
Can't find a good explanation? Sign up and we'll make it for you
Sign up