Hook: Spring Cache is the sticky note on expensive work: if the answer for the same input has not changed, why pay for it twice?
Question: What is Spring Cache in Spring Boot, and how do you use it in production?
Answer: Spring Cache is a simple abstraction that lets you store method results and reuse them later instead of recomputing them. You turn it on with @EnableCaching and then use annotations like @Cacheable, @CachePut, and @CacheEvict on Spring beans. In production, the big ideas are: choose the right cache provider, define stable keys, and evict or update cached data when the source of truth changes.
Interview-Ready Answer: I use Spring Cache when a method is expensive but returns the same result for the same input. With @EnableCaching, Spring creates a proxy around the bean; on a cache hit it returns the stored value, and on a miss it runs the method and stores the result. I usually pair @Cacheable with explicit keys and a real provider like Caffeine or Redis, then use @CacheEvict or @CachePut to keep the data fresh.
Spring Cache is an abstraction (a common layer over different cache tools) for saving method outputs. The important mental model is simple: the method becomes the source of truth for building the value, but the cache becomes the fast path for repeated reads.
@EnableCaching.key is safer because it makes the rule obvious.condition, Spring checks it before the method runs. This is useful when you want to skip caching for certain requests.@Cacheable, Spring checks the cache first. On a hit, it returns the cached value immediately. On a miss, it runs the method, then stores the returned value.unless, Spring evaluates it after the method returns. This is how you can say, “do not cache nulls” or “do not cache very large results.”@CachePut always executes the method and then updates the cache, while @CacheEvict removes one key or all entries. With beforeInvocation = true, eviction happens even if the method later fails.| Annotation | Behavior | Best use |
|---|---|---|
@Cacheable | Lookup first | Read-heavy data |
@CachePut | Always run method | Refresh after write |
@CacheEvict | Remove entry | Invalidate stale data |
sync=true can reduce a thundering herd (many requests missing the cache at once) on one JVM, but it is not a distributed lock across all app instances.One more production rule: cache keys must include everything that changes the output. If the same method returns different results by user, locale, tenant, or currency, those values belong in the key or the method should not be cached.
Real-World Example: Imagine a checkout service in an e-commerce platform. Product detail pages are hit thousands of times per minute, but the database row for a product changes only a few times a day. Spring Cache can store the product lookup result so the API returns in milliseconds instead of re-reading the database on every request.
Now the outage story: a developer updates the price in the database but forgets to evict or refresh the cache. The service keeps serving the old price for 15 minutes, and customers see a mismatch between the cart and checkout total. Symptoms include high cache hit rates, logs showing the update method ran, but the read endpoint never hitting the repository. Support gets complaints like “the page says one price and checkout says another,” and the root cause is stale cached data.
That is why production caching is not just about speed; it is about controlled freshness.
package com.example.springcache;
import java.math.BigDecimal;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import jakarta.annotation.PostConstruct;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@EnableCaching
public class SpringCacheApplication {
public static void main(String[] args) {
SpringApplication.run(SpringCacheApplication.class, args);
}
// Demo-friendly cache manager: easy to run, but not shared across nodes and has no TTL.
@Bean
CacheManager cacheManager() {
return new ConcurrentMapCacheManager("products");
}
}
record Product(String sku, String name, BigDecimal price) {}
@Service
class ProductService {
private final Map<String, Product> db = new ConcurrentHashMap<>();
private final AtomicInteger expensiveLookups = new AtomicInteger();
@PostConstruct
void init() {
db.put("p100", new Product("p100", "Keyboard", new BigDecimal("49.99")));
db.put("p200", new Product("p200", "Mouse", new BigDecimal("19.99")));
}
@Cacheable(cacheNames = "products", key = "#sku", unless = "#result == null")
public Product findProduct(String sku) {
if (sku == null || sku.isBlank()) {
throw new IllegalArgumentException("sku must not be blank");
}
// Pretend this is a database call, remote API call, or expensive calculation.
expensiveLookups.incrementAndGet();
try {
TimeUnit.MILLISECONDS.sleep(250);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return db.get(sku);
}
@CachePut(cacheNames = "products", key = "#sku")
public Product updatePrice(String sku, BigDecimal newPrice) {
Product current = db.get(sku);
if (current == null) {
throw new NoSuchElementException("No product found for sku " + sku);
}
Product updated = new Product(current.sku(), current.name(), newPrice);
db.put(sku, updated);
return updated;
}
@CacheEvict(cacheNames = "products", key = "#sku")
public void deleteProduct(String sku) {
db.remove(sku);
}
public String selfInvocationDemo(String sku) {
// These two calls do NOT go through the caching proxy because they are self-invocations.
Product first = findProduct(sku);
Product second = findProduct(sku);
return "first=" + first + ", second=" + second + ", lookupCount=" + expensiveLookups.get();
}
public int lookupCount() {
return expensiveLookups.get();
}
}
@RestController
class ProductController {
private final ProductService service;
ProductController(ProductService service) {
this.service = service;
}
@GetMapping("/products/{sku}")
ResponseEntity<Product> get(@PathVariable String sku) {
Product product = service.findProduct(sku);
return product == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(product);
}
@PutMapping("/products/{sku}/price/{price}")
ResponseEntity<Product> update(@PathVariable String sku, @PathVariable BigDecimal price) {
return ResponseEntity.ok(service.updatePrice(sku, price));
}
@DeleteMapping("/products/{sku}")
ResponseEntity<Void> delete(@PathVariable String sku) {
service.deleteProduct(sku);
return ResponseEntity.noContent().build();
}
@GetMapping("/stats")
Map<String, Object> stats() {
return Map.of("lookupCount", service.lookupCount());
}
@GetMapping("/demo/self-call/{sku}")
ResponseEntity<String> selfCall(@PathVariable String sku) {
return ResponseEntity.ok(service.selfInvocationDemo(sku));
}
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<String> badRequest(IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
}
@ExceptionHandler(NoSuchElementException.class)
ResponseEntity<String> notFound(NoSuchElementException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
}
}
Follow-up & Tricky Questions:
@Cacheable different from @CachePut? @Cacheable tries the cache first and skips the method on a hit, while @CachePut always runs the method and then writes the result. Use @CachePut when a write operation must refresh the cache immediately.@CacheEvict on the write path, or use @CachePut when the method already has the fresh value and you want to store it right away. For bulk changes, allEntries = true clears the whole cache.condition and unless? condition runs before the method and decides whether caching should happen at all. unless runs after the method and can veto storing the returned value.key attribute with a stable expression, usually SpEL (Spring Expression Language, a small expression language for Spring metadata). In production I prefer explicit keys so the rule is obvious to the next engineer.unless. Many teams avoid caching null for data that might appear soon after.Tricky / Gotcha Questions:
@Cacheable enough for write operations? Usually not. A write path often needs @CachePut or @CacheEvict because caching a read result does not magically keep it fresh after updates.Common Mistakes:
key when the method output depends on more than one argument or on hidden context like tenant or locale.@CachePut or @CacheEvict so updates do not leave stale values behind.Memory Hook: Think of Spring Cache like a restaurant ticket board. If the same order comes back, the kitchen points to the ticket and serves the plate immediately; if the recipe changes, the old ticket must be torn down or you will serve the wrong meal.
Cheat Sheet:
@EnableCaching turns the feature on.@Cacheable reads first, then computes on a miss.@CachePut always computes and refreshes the cache.@CacheEvict removes stale data.Practice Tasks: