DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Same request. Same answer. One is ~120ms, the other is ~2ms. The only difference is whether it came from Postgres or from Redis.

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));
}
Enter fullscreen mode Exit fullscreen mode

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 { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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) { ... }
Enter fullscreen mode Exit fullscreen mode

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) { ... }
Enter fullscreen mode Exit fullscreen mode

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) { ... }
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

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'] })
}
Enter fullscreen mode Exit fullscreen mode

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)