DEV Community

Muralidharan Lakshmanan
Muralidharan Lakshmanan

Posted on

Let's Build a Cache: Redis + Spring Boot

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.

What we're building: a REST client calls Spring Boot, which checks Redis and only falls through to the database on a miss — the shape this entire post implements piece by piece


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
}
Enter fullscreen mode Exit fullscreen mode
public interface ProductRepository
        extends JpaRepository<Product, Long> {
}
Enter fullscreen mode Exit fullscreen mode

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

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

and the connection is standard Spring Boot configuration:

spring:
  data:
    redis:
      host: localhost
      port: 6379
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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:

The N+1 cache problem: GET /orders/1001 fans out into five separate cache operations — one customer lookup and four product lookups — all sitting on the same request's critical path

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

The two paths this implementation supports: GET /products/101 checks Redis, returns immediately on a hit, or falls through to the database and populates Redis on a miss; PUT /products/101 updates the database, deletes the Redis entry, and lets the next GET rebuild it from scratch

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)