API Caching Layer
Take a plain Spring Boot Products REST API (Postgres-backed) and bolt on a Redis
caching layer using Spring’s declarative cache annotations. You write zero
hand-rolled GET/SET Redis calls — @Cacheable, @CachePut, and @CacheEvict
turn the cache-aside pattern into annotations on your service methods, and Spring
handles the lookup → miss → load → store dance for you.
What you’ll build
Section titled “What you’ll build”A ProductService whose read methods are cached and whose writes keep the cache
coherent:
| Method | URL | Cache behavior |
|---|---|---|
| GET | /api/products | No caching (always fresh) |
| GET | /api/products/{id} | Cached (@Cacheable) |
| GET | /api/products/category/{cat} | Cached (@Cacheable) |
| POST | /api/products | Writes through (@CachePut + evicts category cache) |
| PUT | /api/products/{id} | Writes through (@CachePut + evicts category cache) |
| DELETE | /api/products/{id} | Evicts (@CacheEvict) |
If you’re coming from TS/Go: this is the same cache-aside pattern you’d write by
hand around a redis.get(key) ?? loadFromDb(), except Spring weaves it in via AOP
proxies — the annotation IS the wiring. The TTLs live in config, not scattered
through call sites.
How cache-aside reads work
Section titled “How cache-aside reads work”A @Cacheable method is intercepted by a proxy. On each call Spring computes a
key, checks Redis first, and only runs your method body on a miss — then stores
the return value before handing it back.
sequenceDiagram participant C as Controller participant P as Cache proxy participant R as Redis participant S as Service body participant DB as Postgres C->>P: getProduct(1) P->>R: GET products::1 alt cache hit R-->>P: cached ProductDTO P-->>C: return (no DB query) else cache miss R-->>P: nil P->>S: run method body S->>DB: findById(1) DB-->>S: Product row S-->>P: ProductDTO P->>R: SET products::1 (TTL 30m) P-->>C: return end
The worked solution
Section titled “The worked solution”A standard Spring Boot layered project. The caching lives entirely in two files:
CacheConfig.kt (the Redis cache manager + per-cache TTLs) and ProductService.kt
(the annotations). Everything else is an ordinary JPA-backed CRUD API.
Directoryapi-caching/
- build.gradle.kts Spring Boot, JPA, Data Redis, Flyway
- settings.gradle.kts project name
Directorysrc/main/
Directorykotlin/com/example/
- Application.kt Spring Boot entry point
Directoryconfig/
- CacheConfig.kt Redis cache manager + TTLs
Directorycontroller/
- ProductController.kt REST endpoints
Directoryservice/
- ProductService.kt the cache-aside annotations
Directoryrepository/
- ProductRepository.kt Spring Data JPA
Directorymodel/
- Product.kt JPA entity
Directorydto/
- ProductDTO.kt request/response types
Directoryresources/
- application.yml datasource, redis, cache config
Directorydb/migration/
- V1__create_products.sql schema + seed data
build.gradle.kts
Section titled “build.gradle.kts”The one dependency that enables everything is
spring-boot-starter-data-redis — it pulls in the Lettuce client and the
RedisCacheManager Spring needs to back the cache abstraction with Redis.
dependencies { // Spring Boot implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-redis")
// Database runtimeOnly("org.postgresql:postgresql") implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-database-postgresql")
// Jackson implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
// Kotlin implementation("org.jetbrains.kotlin:kotlin-reflect")
testImplementation("org.springframework.boot:spring-boot-starter-test")}CacheConfig.kt
Section titled “CacheConfig.kt”This is where the cache-aside layer is configured. @EnableCaching turns on
Spring’s annotation processing; the cacheManager bean tells Spring to use Redis
as the backing store and sets the serialization and TTL policy.
A few things worth calling out if you’ve only ever set TTLs inline with
SET key val EX 1800:
- Keys are serialized as plain strings (
StringRedisSerializer) so they’re readable inredis-cli: you getproducts::1, not a binary blob. - Values are serialized as JSON (
GenericJackson2JsonRedisSerializer), which is why a cachedProductDTOround-trips cleanly. This also embeds type info so Jackson can deserialize back into the right class. disableCachingNullValues()means anullreturn is never cached — a missing product won’t poison the cache with a negative entry.- TTLs are set per cache name: the default is 10 minutes, but the
productscache gets 30 minutes andproducts-by-categorygets 15. The cache name is the first argument you pass to@Cacheable(value = ["products"]).
package com.example.config
import org.springframework.cache.CacheManagerimport org.springframework.cache.annotation.EnableCachingimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport org.springframework.data.redis.cache.RedisCacheConfigurationimport org.springframework.data.redis.cache.RedisCacheManagerimport org.springframework.data.redis.connection.RedisConnectionFactoryimport org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializerimport org.springframework.data.redis.serializer.RedisSerializationContextimport org.springframework.data.redis.serializer.StringRedisSerializerimport java.time.Duration
@Configuration@EnableCachingclass CacheConfig {
@Bean fun cacheManager(connectionFactory: RedisConnectionFactory): CacheManager { val defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) .serializeKeysWith( RedisSerializationContext.SerializationPair .fromSerializer(StringRedisSerializer()) ) .serializeValuesWith( RedisSerializationContext.SerializationPair .fromSerializer(GenericJackson2JsonRedisSerializer()) ) .disableCachingNullValues()
val cacheConfigs = mapOf( "products" to defaultConfig.entryTtl(Duration.ofMinutes(30)), "products-by-category" to defaultConfig.entryTtl(Duration.ofMinutes(15)) )
return RedisCacheManager.builder(connectionFactory) .cacheDefaults(defaultConfig) .withInitialCacheConfigurations(cacheConfigs) .build() }}ProductService.kt
Section titled “ProductService.kt”The heart of the exercise. Each annotation maps one of the cache-aside operations onto a method — no Redis client appears anywhere in this file.
@Cacheable(value = ["products"], key = "#id")is the read path from the diagram above: check Redis under keyproducts::<id>, return on hit, otherwise run the body, store the result. The"Cache MISS"log line only fires when the body actually runs — that’s your hit/miss signal in the logs.key = "#id"is a SpEL (Spring Expression Language) expression:#idrefers to the method’sidparameter. The full Redis key becomescacheName::key, e.g.products::1.- Writes use
@Cachingto combine two effects:@CachePutalways runs the method and stores the fresh result (so theproductscache stays warm and correct), while@CacheEvict(allEntries = true)clears the entireproducts-by-categorycache — because a create or update could change which category list a product belongs to, and there’s no cheap way to know which keyed lists are now stale. @CachePut(key = "#result.id")keys off the returned DTO’sid(#resultis the method’s return value), since on a create the id isn’t known until after the insert.deleteProductevicts the singleproducts::<id>entry and flushes the category cache.getAllProductsis deliberately uncached — the list endpoint is always fresh.
package com.example.service
import com.example.dto.CreateProductRequestimport com.example.dto.ProductDTOimport com.example.dto.UpdateProductRequestimport com.example.model.Productimport com.example.repository.ProductRepositoryimport org.slf4j.LoggerFactoryimport org.springframework.cache.annotation.CacheEvictimport org.springframework.cache.annotation.CachePutimport org.springframework.cache.annotation.Cacheableimport org.springframework.cache.annotation.Cachingimport org.springframework.stereotype.Serviceimport org.springframework.transaction.annotation.Transactionalimport java.time.Instant
@Serviceclass ProductService(private val productRepository: ProductRepository) {
private val logger = LoggerFactory.getLogger(javaClass)
@Cacheable(value = ["products"], key = "#id") fun getProduct(id: Long): ProductDTO { logger.info("Cache MISS — fetching product $id from database") return productRepository.findById(id) .orElseThrow { NoSuchElementException("Product $id not found") } .toDTO() }
@Cacheable(value = ["products-by-category"], key = "#category") fun getProductsByCategory(category: String): List<ProductDTO> { logger.info("Cache MISS — fetching products for category '$category' from database") return productRepository.findByCategory(category).map { it.toDTO() } }
@Transactional @Caching( put = [CachePut(value = ["products"], key = "#result.id")], evict = [CacheEvict(value = ["products-by-category"], allEntries = true)] ) fun createProduct(request: CreateProductRequest): ProductDTO { val product = productRepository.save( Product( name = request.name, description = request.description, price = request.price, category = request.category ) ) logger.info("Created product ${product.id}") return product.toDTO() }
@Transactional @Caching( put = [CachePut(value = ["products"], key = "#id")], evict = [CacheEvict(value = ["products-by-category"], allEntries = true)] ) fun updateProduct(id: Long, request: UpdateProductRequest): ProductDTO { val product = productRepository.findById(id) .orElseThrow { NoSuchElementException("Product $id not found") } product.name = request.name product.description = request.description product.price = request.price product.category = request.category product.inStock = request.inStock product.updatedAt = Instant.now() logger.info("Updated product $id") return productRepository.save(product).toDTO() }
@Caching(evict = [ CacheEvict(value = ["products"], key = "#id"), CacheEvict(value = ["products-by-category"], allEntries = true) ]) fun deleteProduct(id: Long) { if (!productRepository.existsById(id)) { throw NoSuchElementException("Product $id not found") } productRepository.deleteById(id) logger.info("Deleted product $id") }
// No caching — always fresh fun getAllProducts(): List<ProductDTO> { return productRepository.findAll().map { it.toDTO() } }
private fun Product.toDTO() = ProductDTO( id = id, name = name, description = description, price = price, category = category, inStock = inStock, createdAt = createdAt, updatedAt = updatedAt )}ProductController.kt
Section titled “ProductController.kt”The controller is a thin pass-through to the service — it has no idea caching exists. That’s the point: caching is a service-layer concern, invisible to the HTTP layer.
@RestController@RequestMapping("/api/products")class ProductController(private val productService: ProductService) {
@GetMapping fun listAll(): List<ProductDTO> = productService.getAllProducts()
@GetMapping("/{id}") fun getById(@PathVariable id: Long): ProductDTO = productService.getProduct(id)
@GetMapping("/category/{category}") fun getByCategory(@PathVariable category: String): List<ProductDTO> = productService.getProductsByCategory(category)
@PostMapping fun create(@RequestBody request: CreateProductRequest): ResponseEntity<ProductDTO> { val product = productService.createProduct(request) return ResponseEntity.status(HttpStatus.CREATED).body(product) }
@PutMapping("/{id}") fun update( @PathVariable id: Long, @RequestBody request: UpdateProductRequest ): ProductDTO = productService.updateProduct(id, request)
@DeleteMapping("/{id}") fun delete(@PathVariable id: Long): ResponseEntity<Void> { productService.deleteProduct(id) return ResponseEntity.noContent().build() }}application.yml
Section titled “application.yml”Two blocks wire the cache: spring.data.redis points at the Redis instance, and
spring.cache.type: redis tells Spring to back the cache abstraction with Redis.
The app runs on port 8082.
spring: datasource: url: jdbc:postgresql://localhost:5432/kotlin_course username: dev password: dev
jpa: hibernate: ddl-auto: validate properties: hibernate: default_schema: mod11_redis
data: redis: host: localhost port: 6379
cache: type: redis
server: port: 8082Run it
Section titled “Run it”You need both Postgres and Redis running first — they come from the course’s
shared-infra Docker Compose stack.
-
Start Postgres and Redis:
Terminal window cd shared-infra && docker compose up -d postgres redis -
Run the app (Flyway applies
V1__create_products.sqland seeds five products on first boot):Terminal window ./gradlew bootRun
Test the cache
Section titled “Test the cache”Watch the application logs for the Cache MISS line — it only prints when the
service method body actually runs, so its absence on a repeat call is your proof
of a cache hit.
-
First call — a cache miss. Check the logs for
Cache MISS:Terminal window curl http://localhost:8082/api/products/1 -
Second call — a cache hit. No
Cache MISSlog this time:Terminal window curl http://localhost:8082/api/products/1 -
Update the product —
@CachePutrefreshes the cache in place:Terminal window curl -X PUT http://localhost:8082/api/products/1 \-H "Content-Type: application/json" \-d '{"name":"Updated Book","description":"Updated desc","price":59.99,"category":"books","inStock":true}' -
Read again — fresh data, still no
Cache MISS(the cache was updated, not invalidated):Terminal window curl http://localhost:8082/api/products/1 -
Inspect Redis directly. Keys are stored as
cacheName::key, so the product with id 1 lives atproducts::1:Terminal window redis-cli KEYS "*products*"redis-cli GET "products::1"redis-cli TTL "products::1"