System Design Interview: Replacing Redis with Own Application in Spring Boot

It was a Wednesday afternoon when I stepped into the virtual meeting room for my senior Java backend developer interview. After the usual introductions and technical questions, the lead architect leaned forward with a challenging gleam in her eye. “Let’s discuss a system design problem,” she said. “Imagine we’re looking to replace our Redis caching layer with a solution built directly into our Spring Boot microservices. How would you approach this?” I took a moment to gather my thoughts, recognizing this as an opportunity to demonstrate my architectural thinking. “Before diving into implementation details,” I started, “could you tell me more about the current microservices ecosystem and how Redis is being used?” “Good question,” she replied. “We have about a dozen microservices, each responsible for specific business domains. Redis currently acts as a centralized caching layer accessed by all services for performance optimization and to reduce database load. We’re particularly interested in exploring alternatives that might reduce operational complexity.” I nodded and began outlining my solution. Understanding the Microservice Caching Requirements “First, let’s identify what we need from our replacement caching solution in a microservices context,” I said. Distributed consistency — Ensuring cache coherence across service instances Independent scaling — Each service managing its own cache needs Resilience — Preventing cascading failures if caching issues arise Performance — Maintaining similar or better performance than Redis Observability — Monitoring cache effectiveness across services The Architecture Approach “I propose a two-tiered architecture,” I continued. “Each microservice will maintain its own local cache, but we’ll implement a synchronization mechanism to maintain consistency where needed.” I sketched out a diagram showing: Individual Spring Boot microservices with embedded caching components An event-driven synchronization layer for cross-service cache invalidation Optional persistence mechanisms per service Core Implementation “For the foundation, we’ll use Spring’s cache abstraction,” I explained. “This gives us flexibility to implement different caching strategies per microservice based on their specific needs.” @Configuration @EnableCaching public class CachingConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager(); cacheManager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(Duration.ofMinutes(30)) .maximumSize(10000) .recordStats()); return cacheManager; } @Bean public CacheMetrics cacheMetrics(CacheManager cacheManager) { return new CacheMetrics(cacheManager, meterRegistry); } “I’m using Caffeine as the local cache provider since it offers excellent performance characteristics similar to Redis,” I explained. “Each microservice can configure its own cache policies based on its specific access patterns and data freshness requirements.” Distributed Cache Invalidation “The key challenge in a microservices architecture is maintaining cache consistency,” I continued. “When one service updates data, other services’ caches might become stale.” “For this, I recommend an event-driven approach using a lightweight message broker like RabbitMQ or Kafka, which you likely already have in your microservices ecosystem.” @Service public class CacheInvalidationService { @Autowired private CacheManager cacheManager; @Autowired private EventPublisher eventPublisher; public void invalidateCache(String cacheName, String key) { // Local invalidation Cache cache = cacheManager.getCache(cacheName); if (cache != null) { cache.evict(key); } // Publish invalidation event for other services eventPublisher.publish(new CacheInvalidationEvent(cacheName, key)); } @EventListener public void handleInvalidationEvent(CacheInvalidationEvent event) { Cache cache = cacheManager.getCache(event.getCacheName()); if (cache != null) { cache.evict(event.getKey()); } } } “This approach allows for targeted cache invalidation across services. Only the specific keys that have changed are invalidated, rather than flushing entire caches.” Service-Specific Caching Strategies “A key advantage of moving caching into each microservice is the ability to tailor caching strategies to each service’s needs,” I explained. “For example, a product catalog service might cache items with long TTLs, while a pricing service might use shorter TTLs or implement cache-aside with database triggers for invalidation. @Service public class ProductCatalogService { @Cacheable(value = "products", key = "#productId", unless = "#result == null") public Product getProdu

Apr 24, 2025 - 10:39
 0
System Design Interview: Replacing Redis with Own Application in Spring Boot

It was a Wednesday afternoon when I stepped into the virtual meeting room for my senior Java backend developer interview. After the usual introductions and technical questions, the lead architect leaned forward with a challenging gleam in her eye.

“Let’s discuss a system design problem,” she said. “Imagine we’re looking to replace our Redis caching layer with a solution built directly into our Spring Boot microservices. How would you approach this?”

I took a moment to gather my thoughts, recognizing this as an opportunity to demonstrate my architectural thinking.

“Before diving into implementation details,” I started, “could you tell me more about the current microservices ecosystem and how Redis is being used?”

“Good question,” she replied.

“We have about a dozen microservices, each responsible for specific business domains. Redis currently acts as a centralized caching layer accessed by all services for performance optimization and to reduce database load. We’re particularly interested in exploring alternatives that might reduce operational complexity.”

I nodded and began outlining my solution.

Understanding the Microservice Caching Requirements

“First, let’s identify what we need from our replacement caching solution in a microservices context,” I said.

  1. Distributed consistency — Ensuring cache coherence across service instances
  2. Independent scaling — Each service managing its own cache needs
  3. Resilience — Preventing cascading failures if caching issues arise
  4. Performance — Maintaining similar or better performance than Redis
  5. Observability — Monitoring cache effectiveness across services

The Architecture Approach

“I propose a two-tiered architecture,” I continued. “Each microservice will maintain its own local cache, but we’ll implement a synchronization mechanism to maintain consistency where needed.”

Replacing Redis with Own Application in Spring Boot

I sketched out a diagram showing:

  • Individual Spring Boot microservices with embedded caching components
  • An event-driven synchronization layer for cross-service cache invalidation
  • Optional persistence mechanisms per service

Core Implementation

“For the foundation, we’ll use Spring’s cache abstraction,” I explained. “This gives us flexibility to implement different caching strategies per microservice based on their specific needs.”

@Configuration
@EnableCaching
public class CachingConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofMinutes(30))
            .maximumSize(10000)
            .recordStats());
        return cacheManager;
    }

    @Bean
    public CacheMetrics cacheMetrics(CacheManager cacheManager) {
        return new CacheMetrics(cacheManager, meterRegistry);
    }

“I’m using Caffeine as the local cache provider since it offers excellent performance characteristics similar to Redis,” I explained. “Each microservice can configure its own cache policies based on its specific access patterns and data freshness requirements.”

Distributed Cache Invalidation

“The key challenge in a microservices architecture is maintaining cache consistency,” I continued. “When one service updates data, other services’ caches might become stale.”

“For this, I recommend an event-driven approach using a lightweight message broker like RabbitMQ or Kafka, which you likely already have in your microservices ecosystem.”

@Service
public class CacheInvalidationService {

    @Autowired
    private CacheManager cacheManager;

    @Autowired
    private EventPublisher eventPublisher;

    public void invalidateCache(String cacheName, String key) {
        // Local invalidation
        Cache cache = cacheManager.getCache(cacheName);
        if (cache != null) {
            cache.evict(key);
        }

        // Publish invalidation event for other services
        eventPublisher.publish(new CacheInvalidationEvent(cacheName, key));
    }

    @EventListener
    public void handleInvalidationEvent(CacheInvalidationEvent event) {
        Cache cache = cacheManager.getCache(event.getCacheName());
        if (cache != null) {
            cache.evict(event.getKey());
        }
    }
}

“This approach allows for targeted cache invalidation across services. Only the specific keys that have changed are invalidated, rather than flushing entire caches.”

Service-Specific Caching Strategies

“A key advantage of moving caching into each microservice is the ability to tailor caching strategies to each service’s needs,” I explained.

“For example, a product catalog service might cache items with long TTLs, while a pricing service might use shorter TTLs or implement cache-aside with database triggers for invalidation.

@Service
public class ProductCatalogService {

    @Cacheable(value = "products", key = "#productId", unless = "#result == null")
    public Product getProduct(String productId) {
        return productRepository.findById(productId);
    }

    @CachePut(value = "products", key = "#product.id")
    public Product updateProduct(Product product) {
        Product updated = productRepository.save(product);
        // Notify other services about this update
        cacheInvalidationService.notifyUpdate("products", product.getId());
        return updated;
    }
}

Near Cache Pattern Implementation

“For frequently accessed data that’s shared across services, I’d implement a near cache pattern,” I suggested.

“Each service maintains its local cache, but also subscribes to a topic for updates from authoritative services. This gives us the best of both worlds: local cache speed with reasonable consistency.”

@Service
public class NearCacheService {

    @Autowired
    private CacheManager cacheManager;

    @Autowired
    private MessageListener messageListener;

    @PostConstruct
    public void init() {
        // Subscribe to cache update events for domains this service cares about
        messageListener.subscribe("cache.updates.products", this::handleProductUpdate);
    }

    private void handleProductUpdate(CacheUpdateEvent event) {
        // Update local cache with fresh data
        Cache cache = cacheManager.getCache("products");
        if (cache != null) {
            cache.put(event.getKey(), event.getValue());
        }
    }
}

Resilience Patterns

“In a distributed system, we need to ensure cache failures don’t impact service availability,” I noted. “I’d implement circuit breakers around cache operations to gracefully degrade to direct database access if needed.”

@Service
public class ResilientCacheService {

    @Autowired
    private CacheManager cacheManager;

    @Autowired
    private CircuitBreakerFactory circuitBreakerFactory;

    public Object getWithFallback(String cacheName, String key, Supplier<Object> dataLoader) {
        CircuitBreaker circuitBreaker = circuitBreakerFactory.create("cache");

        return circuitBreaker.run(() -> {
            Cache cache = cacheManager.getCache(cacheName);
            Object value = cache.get(key);
            if (value != null) {
                return value;
            }

            // Cache miss or error, load from source
            Object freshData = dataLoader.get();
            if (freshData != null) {
                cache.put(key, freshData);
            }
            return freshData;
        }, throwable -> {
            // Circuit open or error, direct fallback to data source
            return dataLoader.get();
        });
    }
}

Monitoring and Observability

“For monitoring, we’ll implement comprehensive metrics collection,” I continued. “This is especially important in a distributed caching system to identify inefficiencies or inconsistencies.”

@Component
public class CacheMetrics {

    private final CacheManager cacheManager;
    private final MeterRegistry meterRegistry;

    public CacheMetrics(CacheManager cacheManager, MeterRegistry meterRegistry) {
        this.cacheManager = cacheManager;
        this.meterRegistry = meterRegistry;

        registerMetrics();
    }

    private void registerMetrics() {
        cacheManager.getCacheNames().forEach(cacheName -> {
            Cache cache = cacheManager.getCache(cacheName);
            if (cache instanceof CaffeineCache) {
                com.github.benmanes.caffeine.cache.Cache nativeCache =
                    ((CaffeineCache) cache).getNativeCache();

                // Register various metrics for this cache
                Gauge.builder("cache.size", nativeCache, c -> c.estimatedSize())
                    .tag("cache", cacheName)
                    .register(meterRegistry);

                // Add more metrics for hits, misses, etc.
            }
        });
    }
}

Migration Strategy

“Replacing Redis across a microservices ecosystem requires careful planning,” I said. “I recommend a phased approach:

  1. Implement the new caching solution alongside Redis in non-critical services first
  2. Use feature flags to control traffic between the two caching systems
  3. Monitor performance, errors, and consistency issues closely
  4. Gradually migrate services from least to most critical
  5. Maintain Redis as a fallback until confidence is high in the new solution

Conclusion

This approach offers several advantages in a microservices context,

I summarized:

  1. Reduced operational complexity — No separate Redis cluster to maintain
  2. Service autonomy — Each service controls its own caching behavior
  3. Improved resilience — No single point of failure for the entire caching system
  4. Tailored performance — Cache configurations aligned with specific service needs
  5. Simplified deployment — No external cache dependencies for development or testing

The interviewer nodded thoughtfully.

What about the drawbacks? When would you recommend keeping Redis instead?

“Great question,” I replied. “I’d recommend keeping Redis if:

  1. Your services need advanced data structures like sorted sets or geospatial indexes
  2. The volume of cached data is extremely large and would strain service memory
  3. You need centralized rate limiting or distributed locking features
  4. Your architecture requires centralized caching policies for compliance reasons
  5. The overhead of implementing and maintaining cache synchronization exceeds Redis operational costs”

Okay, … part of the interview, wait for good results from me.

original my article from Medium