This is Day 11 of building OrderHub — one production-grade Spring Boot + React app, one feature a day. Phase 1 built a rock-solid monolith (REST, JPA, Flyway, validation, error handling, pagination, config, tests, OpenAPI, Docker). Phase 2 is about making it fast and resilient, and it starts with the highest-leverage performance win there is: caching hot reads in Redis with @Cacheable and @CacheEvict.
🌐 Interactive learning hub (click through the hit/miss demo): https://dev48v.infy.uk/orderhub.php
👉 Repo (read the commits in order): https://github.com/dev48v/order-hub-from-zero
The problem: reading the same row a thousand times
An order gets read constantly — every detail-page open, every refresh, every downstream status check — but it barely ever changes. Yet every one of those reads is currently a full SQL round-trip:
public Order getOrder(String id) {
return repository.findById(id) // SQL query, EVERY call
.orElseThrow(() -> new OrderNotFoundException(id));
}
Identical result, full query cost, over and over. The database becomes the bottleneck long before your app does. The fix is the oldest trick in computing: if you just computed an answer and it hasn't changed, keep a copy somewhere fast.
Redis, in one paragraph
Redis is an in-memory key/value store — data in RAM, so reads are sub-millisecond. You SET a key, GET it back, DEL it, optionally with a TTL. That maps onto a cache perfectly: the key is what you looked up (an order id), the value is the answer (the order), the TTL bounds staleness. And because it's a separate networked process, every instance of your app shares one cache.
The cache-aside pattern, declared not plumbed
You could hand-write "check the key, on a miss run the query, store the result, remember to delete on writes" all over your service. Don't. Spring's caching abstraction lets you declare it. Turn it on once:
@Configuration
@EnableCaching
public class CacheConfig { /* ... */ }
Add the starter (spring-boot-starter-data-redis, which brings the Lettuce client), and then you only ever annotate methods.
@Cacheable(cacheNames = "order", key = "#id", unless = "#result == null")
public Order getOrder(String id) {
return repository.findById(id) // runs ONLY on a cache miss
.orElseThrow(() -> new OrderNotFoundException(id));
}
On a hit, Spring returns the stored value and the body never runs — the database is untouched. On a miss, it runs the body, stores the returned Order under the key, and returns it. That's cache-aside, for free.
The key strategy is the design decision. key = "#id" caches each order under its own entry (order::42, order::43), so they're evicted independently. (Gotcha: caching works through a proxy, so a self-call this.getOrder(id) bypasses it — only cross-bean calls are cached.)
Configuring the manager: JSON values and a TTL
@EnableCaching needs a CacheManager that says how to store things. Two decisions matter.
Serialization. Redis stores bytes. GenericJackson2JsonRedisSerializer stores JSON — readable in redis-cli, language-neutral, and it embeds the Java type so it deserializes back to the right class. (Java's built-in serialization produces opaque, version-brittle blobs. Use JSON.)
A default TTL. Give every entry an expiry — a safety net so a stale value can only live so long even if an eviction were missed, and dead keys clean themselves up.
@Bean
RedisCacheManager cacheManager(RedisConnectionFactory cf,
GenericJackson2JsonRedisSerializer json) {
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeValuesWith(fromSerializer(json));
return RedisCacheManager.builder(cf)
.cacheDefaults(defaults)
.withInitialCacheConfigurations(Map.of(
"orders", defaults.entryTtl(Duration.ofMinutes(30)))) // per-cache override
.build();
}
Connection details come from config — spring.data.redis.host/port, localhost in dev, ${REDIS_HOST}/${REDIS_PORT} in prod so no address is baked into the jar.
The immutable-object snag
OrderHub's Order is immutable: all fields final, no setters, no no-arg constructor. Great design — and exactly what naive JSON deserialization chokes on, since it wants an empty object plus setters. The fix doesn't weaken the domain; you just tell Jackson how to rebuild it:
@JsonCreator
private Order(@JsonProperty("id") String id,
@JsonProperty("customer") String customer,
@JsonProperty("item") String item,
@JsonProperty("quantity") int quantity,
@JsonProperty("status") OrderStatus status,
@JsonProperty("createdAt") Instant createdAt) { ... }
Register JavaTimeModule (so Instant becomes an ISO-8601 string) and ParameterNamesModule, and it round-trips cleanly. The general lesson: whatever you cache must survive your serializer.
Keeping it honest: evict on every write
A cache that's never invalidated serves stale data forever, and a stale order status is a real bug. So every write evicts what it invalidates. Placing a new order can't touch any existing per-id entry (its id was just minted) — but it changes the list, so it drops the list cache:
@CacheEvict(cacheNames = "orders", allEntries = true)
public Order placeOrder(String customer, String item, int quantity) { ... }
Confirming an order changes an existing row, so both caches can be stale — evict both, stacked with @Caching:
@Caching(evict = {
@CacheEvict(cacheNames = "order", key = "#id"),
@CacheEvict(cacheNames = "orders", allEntries = true)
})
public Order confirmOrder(String id) { ... }
Why evict instead of @CachePut? A lingering stale read is worse than one extra DB round-trip. Evict is simple and always correct; the next read repopulates. Rule of thumb: for every @Cacheable, ask "which writes make this stale?" and evict there.
Prove it against real Redis
Mocking the cache proves nothing about serialization, keys, or eviction — the exact things that break. So the integration test boots a throwaway Redis container next to Postgres (same Testcontainers approach as Day 9):
@Container static final GenericContainer<?> REDIS =
new GenericContainer<>(DockerImageName.parse("redis:7-alpine")).withExposedPorts(6379);
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.data.redis.host", REDIS::getHost);
r.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
}
Now the full-stack test proves it end to end: POST → GET (miss, cached) → GET (hit, no query) → confirm (evict) → GET (fresh CONFIRMED). All 26 tests green, same engine as prod, only Docker required.
The frontend gets a cache too
The server cache is the foundation; the browser compounds it. React Query is cache-aside one layer up — a client cache keyed by a query key mirroring order::id:
useQuery({ queryKey: ['order', id], queryFn: () => getOrder(id), staleTime: 30_000 })
staleTime gives you stale-while-revalidate: revisiting an order paints the cached copy instantly, refetches in the background, and swaps in fresh data — no spinner. And the same eviction discipline applies: after a write, invalidate the matching keys.
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['order', id] }) // FE "@CacheEvict"
qc.invalidateQueries({ queryKey: ['orders'] })
}
Two caches, one discipline: read from the fast copy, evict it on every write.
Operating a cache without getting burned
- Only cache read-heavy, staleness-tolerant data. A low hit rate is just an extra network hop.
- Beware the stampede: when a hot key expires, a flood of misses can hammer the DB at once — use short, jittered TTLs.
-
Bound memory with TTLs plus a Redis eviction policy (
allkeys-lru). - Degrade gracefully: Lettuce connects lazily, so the app boots without Redis; a cache outage should fall back to the DB.
Fast when healthy, correct always, graceful under failure. That mindset carries into the rest of Phase 2: cache strategies (Day 12), rate limiting on Redis (Day 13), and circuit breakers, retries and timeouts (Days 14–15).
Play with the live hit/miss/evict demo and read the annotated backend + frontend steps at the learning hub, and follow the whole build commit by commit in the repo — both linked up top. 🚀
Top comments (0)