The first eight parts of this series covered why caching improves performance, how hits and misses work, TTL and eviction, cache-aside and the other patterns, invalidation, Redis vs. Memcached, stampedes and hot keys, and how to design a production-ready cache architecture. Now let's actually build one.
The stack is one a lot of enterprise Java teams already run: Spring Boot, Redis, and a database behind it. The goal isn't a caching framework — it's something simple enough to fully understand and realistic enough to use as a real starting point.
1. What we're building
A simple product service: GET /products/{id}, backed by a database row like { "id": 101, "name": "MacBook Pro", "price": 1999.00 }. Without caching, every request — even a thousand requests for the same product — goes all the way to the database. With caching, it's Cache-Aside, the pattern from Part 4: check Redis first, fall back to the database on a miss, populate Redis for next time.
2. The stack
Java, Spring Boot, Spring Data Redis, Redis itself, JPA, and a relational database — Postgres or MySQL, it doesn't matter much for this example. The request path is client → Spring Boot → Redis → (on a miss) → database.
3. Start with the database
A plain Product entity and repository, nothing caching-specific yet:
@Entity
public class Product {
@Id
private Long id;
private String name;
private BigDecimal price;
// getters and setters
}
public interface ProductRepository
extends JpaRepository<Product, Long> {
}
Without caching, the service just goes straight through:
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
public Product getProduct(Long id) {
return repository.findById(id)
.orElseThrow(() ->
new ProductNotFoundException(id));
}
}
Every request hits the database. Now let's introduce Redis.
4. Add Redis
Spring Data Redis is the dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
and the connection is standard Spring Boot configuration:
spring:
data:
redis:
host: localhost
port: 6379
5. The simplest Cache-Aside implementation
Using RedisTemplate directly:
@Service
public class ProductService {
private final ProductRepository repository;
private final RedisTemplate<String, Product> redisTemplate;
public ProductService(
ProductRepository repository,
RedisTemplate<String, Product> redisTemplate) {
this.repository = repository;
this.redisTemplate = redisTemplate;
}
public Product getProduct(Long id) {
String key = "product:" + id;
Product cachedProduct =
redisTemplate.opsForValue().get(key);
if (cachedProduct != null) {
return cachedProduct;
}
Product product = repository.findById(id)
.orElseThrow(() ->
new ProductNotFoundException(id));
redisTemplate.opsForValue()
.set(key, product);
return product;
}
}
That's Cache-Aside, fully implemented. On the first request for product:101, Redis misses, the database gets queried, and the result gets written back to Redis before returning. On the second request for the same ID, Redis hits and the database never gets touched — that gap between the two is where the entire performance benefit comes from.
6. Add a TTL
Right now we're storing values indefinitely, which means a stale product can live in the cache forever if invalidation ever fails. Add an expiration:
redisTemplate.opsForValue()
.set(
key,
product,
Duration.ofMinutes(10)
);
The entry now expires automatically after ten minutes — a safety net that holds even when explicit invalidation doesn't fire, which we'll get to shortly.
Resist hardcoding Duration.ofMinutes(10) everywhere it's needed, though. Once you have a product TTL, a user-profile TTL, a recommendations TTL, and a configuration TTL scattered through the codebase, tuning any of them means a code change and a redeploy. Externalizing them instead —
cache:
product:
ttl: 10m
— means freshness can be tuned from configuration, which matters more than it looks like it should the first time you need to change one in production without shipping code.
7. Cache keys matter
We used product:101 rather than a bare 101, and that's deliberate — a bare numeric key can collide across object types (101 the product, 101 the customer, 101 the order all fighting over the same slot). Namespacing avoids that: product:101, customer:101, order:101. Going one step further, product:v1:101 leaves room to version the cached shape later, the same technique covered in Part 8.
8. Serialization — the part that's easy to skip past
Redis stores bytes, not Java objects, so something has to convert between them. JSON is the common choice — readable, easy to inspect, and it round-trips cleanly:
Java Object → JSON → Redis
Redis → JSON → Java Object
It isn't free, though — serialization costs CPU, memory, and bandwidth, and changing the format later can break compatibility with whatever's already sitting in the cache. Treat the serialization format as an architectural decision made once, not a configuration line picked without much thought.
9. Cache invalidation on updates
Now the write path. PUT /products/101 updates the price — the database gets the update, and the cache entry gets deleted rather than updated in place:
public Product updateProduct(Product product) {
Product updated = repository.save(product);
redisTemplate.delete(
"product:" + product.getId()
);
return updated;
}
UPDATE → DELETE CACHE, and the next read rebuilds the entry from the now-current database row. This is the same pattern from Part 5: deleting is simpler than updating in place because the cache is a temporary copy, and when the source of truth changes, the cleanest move is to stop trusting the old copy rather than try to patch it.
10. What if cache invalidation itself fails?
The real production scenario: the database update succeeds, but the Redis delete fails. Now the database says $1,899 and Redis still says $1,999. This is exactly the dual-write problem from Part 5, and the TTL from step 6 is what keeps it from being permanent — the stale entry eventually expires on its own. TTL isn't a substitute for invalidation here; it's the safety net underneath it.
11. What if Redis is completely down?
The read path shouldn't treat Redis as a hard dependency. A reasonable Cache-Aside implementation catches a connection failure and falls through to the database rather than failing the request outright:
try {
Product cached = redisTemplate
.opsForValue()
.get(key);
if (cached != null) {
return cached;
}
} catch (RedisConnectionFailureException ex) {
// Log and continue to database
}
return repository.findById(id)
.orElseThrow(...);
That's the right instinct, but it isn't the whole answer — if Redis is down and traffic is high, this fallback alone can be exactly the flood that overwhelms the database, the failure mode from Part 6 and Part 7. That's what request coalescing, rate limiting, circuit breakers, local caching, and bounded concurrency are for; this try/catch is necessary, not sufficient.
12. Add metrics
An unmeasured cache is hard to operate. At minimum:
meterRegistry.counter("cache.hit").increment();
meterRegistry.counter("cache.miss").increment();
plus cache.error and cache.latency. From hits and misses you get the hit ratio — 9,900 hits against 100 misses is 99% — but a high ratio on its own doesn't mean the system is healthy. A 99% hit ratio is meaningless if the 1% of misses are the expensive ones.
13. The N+1 cache problem
Here's a subtler issue that shows up once caching is actually working. Suppose an endpoint returns an order with a customer and four products:
One API request just became five cache operations. Each one is individually cheap, but at real scale that adds up, and it's easy to miss because no single call looks expensive in isolation. Caching doesn't remove an N+1 access pattern — it just makes each hop in it cheaper. Measure the whole request path, not just the cache calls that look slow on their own.
14. Don't cache everything
Good candidates share a shape: frequent reads, expensive computation or database access, infrequent changes, and staleness the business can tolerate. Poor candidates: highly volatile data, sensitive data without real access controls, data nobody actually requests often, data that's cheap to fetch anyway, and anything where a stale value is genuinely unacceptable.
A cache should exist because it solves a demonstrated performance problem — the same principle from Part 8's "earn the complexity" argument, just applied at the level of an individual field instead of an architecture layer. It shouldn't be a box ticked on an architecture diagram because caching is what's done around here.
15. Putting the read and write paths together
This is a simple, understandable caching architecture — which is exactly the point. Nothing here should surprise anyone reading it for the first time during an incident.
16. What we've built
Working through the steps above, this implementation now has Cache-Aside on the read path, a TTL so entries expire even if invalidation fails, explicit invalidation on writes, structured and versioned cache keys, a deliberate serialization format, basic hit/miss/error observability, and a Redis connection that isn't a hard dependency for every request. The database stays authoritative throughout — Redis is a fast, disposable copy of it, never the other way around.
17. Is this production-ready yet?
Not quite — this is a solid foundation, but a high-traffic production system needs more of what Part 8 covered in full: Redis high availability and clustering, connection pooling, timeouts, circuit breakers, request coalescing, hot-key protection, cache warming, TTL jitter, security, monitoring, and alerting.
There's also a question this post hasn't touched at all: testing. How do you actually prove caching improved anything? How do you test a cache hit, a cache miss, Redis being unavailable, a stale entry, concurrent requests racing each other, expiration timing, and a database failure underneath all of it?
That's genuinely a different skill from writing the cache-aside logic itself, and it's where the next part of this series is headed.
If you've built something close to this, what was the first thing that broke once it hit real traffic — the invalidation path, a serialization mismatch, or something in the failure handling you hadn't tested?



Top comments (0)