Caching Part 9: Hands-On — Code Labs and Real Implementations
Caching Deep Dive(9-part series)
- P7Caching Part 7: In Production — Monitoring, Sizing, and Staying Alive
- P8Caching Part 8: System Design Interviews — Patterns, Trade-offs, and Model Answers
- P9Caching Part 9: Hands-On — Code Labs and Real Implementations(here)

Enough theory. Let's build. This post has complete, runnable implementations you can drop into your project.
Lab 1: Spring Cache Annotations in 5 Minutes
The fastest way to add caching to a Spring Boot application.
Dependencies
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
}Configuration
@Configuration
@EnableCaching
class CacheConfig {
@Bean
fun cacheManager(connectionFactory: RedisConnectionFactory): CacheManager {
val redisCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(GenericJackson2JsonRedisSerializer())
)
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(redisCacheConfig)
.withInitialCacheConfigurations(
mapOf(
"products" to redisCacheConfig.entryTtl(Duration.ofHours(1)),
"users" to redisCacheConfig.entryTtl(Duration.ofMinutes(30)),
"configs" to redisCacheConfig.entryTtl(Duration.ofDays(1))
)
)
.build()
}
}Using the Annotations
@Service
class ProductService(private val repository: ProductRepository) {
// Cache the result — subsequent calls with same ID return cached value
@Cacheable(value = ["products"], key = "#id")
fun getProduct(id: Long): Product {
println("Fetching from database: $id") // Only prints on cache miss
return repository.findById(id).orElseThrow()
}
// Update cache when data changes
@CachePut(value = ["products"], key = "#result.id")
fun updateProduct(id: Long, request: UpdateProductRequest): Product {
return repository.save(request.toEntity(id))
}
// Remove from cache when deleted
@CacheEvict(value = ["products"], key = "#id")
fun deleteProduct(id: Long) {
repository.deleteById(id)
}
// Clear all entries in the products cache
@CacheEvict(value = ["products"], allEntries = true)
fun refreshAllProducts() {
// Useful after bulk updates
}
// Conditional caching — only cache expensive products
@Cacheable(value = ["products"], key = "#id", condition = "#id > 100")
fun getExpensiveProduct(id: Long): Product {
return repository.findById(id).orElseThrow()
}
}The key thing to remember: @Cacheable only works when called from outside the class. Internal method calls bypass the proxy — a common gotcha.
Lab 2: Two-Level Cache (Caffeine L1 + Redis L2)
A production-grade setup where hot data lives in-process for microsecond access, and Redis serves as the shared L2.
Architecture
Implementation
@Configuration
class TwoLevelCacheConfig {
@Bean
fun productL1Cache(): Cache<Long, Product> {
return Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(Duration.ofSeconds(30))
.recordStats()
.build()
}
}
@Service
class ProductService(
private val l1Cache: Cache<Long, Product>,
private val redisTemplate: RedisTemplate<String, Product>,
private val repository: ProductRepository
) {
fun getProduct(id: Long): Product {
// L1: Check local Caffeine cache
val l1Result = l1Cache.getIfPresent(id)
if (l1Result != null) return l1Result
// L2: Check Redis
val l2Result = redisTemplate.opsForValue().get("product:$id")
if (l2Result != null) {
l1Cache.put(id, l2Result) // Promote to L1
return l2Result
}
// L3: Fetch from database
val product = repository.findById(id).orElseThrow()
// Populate both cache layers
l1Cache.put(id, product)
redisTemplate.opsForValue().set("product:$id", product, Duration.ofMinutes(10))
return product
}
fun updateProduct(id: Long, request: UpdateProductRequest): Product {
val updated = repository.save(request.toEntity(id))
// Invalidate both layers
l1Cache.invalidate(id)
redisTemplate.delete("product:$id")
return updated
}
}Lab 3: Docker Compose Setup
A complete local development environment with Redis, PostgreSQL, and your app.
# docker-compose.yml
version: "3.9"
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
postgres:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: cachedemo
POSTGRES_USER: demo
POSTGRES_PASSWORD: demo
volumes:
- postgres-data:/var/lib/postgresql/data
redis-insight:
image: redis/redisinsight:latest
ports:
- "5540:5540"
depends_on:
- redis
volumes:
redis-data:
postgres-data:# Start everything
docker compose up -d
# Verify Redis is running
docker compose exec redis redis-cli ping
# Output: PONG
# Check Redis info
docker compose exec redis redis-cli INFO memoryRedis Insight (port 5540) gives you a web UI to inspect your cache — see keys, memory usage, and hit/miss stats visually.
Lab 4: HTTP Caching Headers
Server-side HTTP caching configuration for Spring Boot:
@RestController
@RequestMapping("/api/products")
class ProductController(private val productService: ProductService) {
@GetMapping("/{id}")
fun getProduct(
@PathVariable id: Long,
response: HttpServletResponse
): ResponseEntity<Product> {
val product = productService.getProduct(id)
// Generate ETag from content hash
val etag = "\"${product.hashCode()}\""
return ResponseEntity.ok()
.eTag(etag)
.cacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES).cachePublic())
.body(product)
}
@GetMapping
fun listProducts(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ResponseEntity<Page<Product>> {
val products = productService.listProducts(page, size)
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
.varyBy("Accept-Language")
.body(products)
}
}What happens:
- First request: Server returns
200 OKwithETagandCache-Control - Subsequent requests: Browser sends
If-None-Match: <etag> - If unchanged: Server returns
304 Not Modified(no body — saves bandwidth)
Lab 5: Benchmark — Before and After Caching
@Service
class ProductBenchmarkService(
private val repository: ProductRepository,
private val cachedService: ProductService // The cached version
) {
fun runBenchmark(iterations: Int = 1000): Map<String, Long> {
val productId = 1L
// Warm up
repeat(10) { repository.findById(productId) }
repeat(10) { cachedService.getProduct(productId) }
// Benchmark: No cache
val uncachedStart = System.nanoTime()
repeat(iterations) { repository.findById(productId) }
val uncachedTime = System.nanoTime() - uncachedStart
// Warm the cache
cachedService.getProduct(productId)
// Benchmark: With cache
val cachedStart = System.nanoTime()
repeat(iterations) { cachedService.getProduct(productId) }
val cachedTime = System.nanoTime() - cachedStart
val uncachedMs = uncachedTime / 1_000_000
val cachedMs = cachedTime / 1_000_000
return mapOf(
"iterations" to iterations.toLong(),
"uncached_total_ms" to uncachedMs,
"cached_total_ms" to cachedMs,
"uncached_avg_ms" to uncachedMs / iterations,
"cached_avg_ms" to cachedMs / iterations,
"speedup" to uncachedMs / maxOf(cachedMs, 1)
)
}
}Typical results:
Average per-query latency hitting PostgreSQL directly
50x improvement. First query still hits DB, subsequent queries are cached.
5000x improvement vs uncached. In-process cache has zero network overhead.
Quick Reference: Spring Cache Annotations
| Annotation | What It Does | When to Use |
|---|---|---|
@Cacheable | Check cache → return cached or fetch + store | Read operations |
@CachePut | Always execute method, update cache with result | Update operations |
@CacheEvict | Remove entry from cache | Delete operations |
@Caching | Combine multiple cache operations | Complex read/write patterns |
Key Takeaways
- Spring Cache annotations get you caching in minutes —
@Cacheable,@CachePut,@CacheEvict - Two-level cache (Caffeine + Redis) gives microsecond reads for hot data
- Docker Compose makes local development easy — Redis, Postgres, and Redis Insight in one command
- HTTP caching headers (ETag, Cache-Control) give you free caching at the browser/CDN level
- Always benchmark — measure before and after to prove the cache is worth the complexity
- Start simple (Spring Cache + Redis), add complexity (L1 cache, CDC) only when you need it
That's the full series! If you've read all 9 parts, you now have a complete mental model for caching in system design and production:
- Fundamentals — when and why
- Landscape — where to place cache
- Strategies — how to read and write
- Eviction — what to remove
- Invalidation — keeping data fresh
- Failure modes — what breaks at scale
- Production — monitoring and operations
- Interviews — how to talk about it
- Hands-On — real implementations
Tags
Related Posts

Caching Part 3: Strategies — Read-Through, Write-Through, and Beyond
The 5 core caching patterns every backend engineer needs — when to use each one, with real trade-off analysis and Java/Kotlin examples.

Caching Part 2: The Landscape — Where to Put Your Cache
From browser to database — every layer of your stack has caching opportunities. Learn where each one fits and how they work together.

Caching Part 7: In Production — Monitoring, Sizing, and Staying Alive
The operational side of caching — what to monitor, how to size your cache, high availability setup, and surviving failures gracefully.