Cache-Aside Pattern with Spring
Interview-ready guide
Interview answer
Cache-aside, also called lazy loading, is the most common caching pattern. When the application needs data, it first checks the cache. If the data is there — a cache hit — it returns immediately without touching the database. If not — a cache miss — it fetches from the database, stores the result in the cache for future requests, and returns the data. This pattern is lazy: data only enters the cache when it's actually requested.
Spring Cache makes this almost invisible. The @Cacheable annotation on a service method automatically implements the check-and-load logic. Spring checks if a result is cached for the given key; if so, returns it without executing the method; if not, executes the method and caches the result before returning. You write normal service code and the caching just works.
The critical counterpart is @CacheEvict for maintaining consistency. When you update or delete a user, the cached version is stale. Annotating the update method with @CacheEvict removes that entry from Redis, forcing the next read to fetch fresh data from the database. A common mistake is forgetting to evict on updates, which leads to users seeing outdated data until the TTL expires.
Cache-Aside Pattern with Spring
Deep Dive & Architecture
Spring Cache Configuration with Redis
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// JSON serialization — human-readable, compatible across app restarts
var objectMapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.activateDefaultTyping(
BasicPolymorphicTypeValidator.builder().allowIfBaseType(Object.class).build(),
ObjectMapper.DefaultTyping.NON_FINAL
);
var jsonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper);
var defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10)) // default TTL: 10 minutes
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(jsonSerializer))
.disableCachingNullValues(); // don't cache null
// Per-cache TTL overrides
var cacheConfigs = Map.of(
"users", defaultConfig.entryTtl(Duration.ofHours(1)),
"products", defaultConfig.entryTtl(Duration.ofMinutes(5)),
"sessions", defaultConfig.entryTtl(Duration.ofDays(1))
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}
Service Layer Caching
@Service
public class UserService {
private final UserRepository userRepository;
// Cache result with key = userId parameter
// Key stored as: users::42 (cacheName::key)
@Cacheable(value = "users", key = "#userId")
public UserDTO getUserById(Long userId) {
// Only executes on cache miss
return userRepository.findById(userId)
.map(UserDTO::fromEntity)
.orElseThrow(() -> new NotFoundException("User not found: " + userId));
}
// Cache multiple users by their IDs
@Cacheable(value = "users", key = "#username.toLowerCase()")
public UserDTO getUserByUsername(String username) {
return userRepository.findByUsername(username)
.map(UserDTO::fromEntity)
.orElseThrow();
}
// Update entity AND update cache (write-through)
@CachePut(value = "users", key = "#result.id")
public UserDTO updateUser(Long userId, UpdateUserRequest request) {
User user = userRepository.findById(userId).orElseThrow();
user.setEmail(request.getEmail());
return UserDTO.fromEntity(userRepository.save(user));
}
// Delete entity AND evict from cache
@CacheEvict(value = "users", key = "#userId")
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
// Clear entire cache (use sparingly — expensive!)
@CacheEvict(value = "users", allEntries = true)
public void clearUserCache() {
log.info("Cleared all user cache entries");
}
// Conditional caching — only cache active users
@Cacheable(value = "users", key = "#userId", condition = "#userId > 0",
unless = "#result.status == 'DELETED'")
public UserDTO getUserWithCondition(Long userId) {
return userRepository.findById(userId).map(UserDTO::fromEntity).orElseThrow();
}
}
Cache Stampede Prevention
// Problem: many requests hit DB simultaneously when a popular cache entry expires
// Solution: use a distributed lock or probabilistic early expiration
@Service
public class ProductService {
private final StringRedisTemplate redisTemplate;
private final ProductRepository productRepository;
private static final String LOCK_PREFIX = "lock:product:";
public ProductDTO getPopularProduct(Long productId) {
String cacheKey = "product:" + productId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) return deserialize(cached, ProductDTO.class);
// Acquire lock — only one request rebuilds cache
String lockKey = LOCK_PREFIX + productId;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
if (Boolean.TRUE.equals(acquired)) {
try {
ProductDTO product = productRepository.findById(productId)
.map(ProductDTO::fromEntity).orElseThrow();
redisTemplate.opsForValue().set(cacheKey, serialize(product), Duration.ofMinutes(10));
return product;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// Another thread is rebuilding — wait briefly and retry
Thread.sleep(100);
return getPopularProduct(productId);
}
}
}
Looking for a structured roadmap?
Every module on this page is also part of the full Java backend interview track.