Hit GET /api/orders/{id} twice in a row and watch the SQL log. Without a cache, Spring Data JPA runs the same select twice. The order did not change. The database still paid for both reads.
This post continues the orders lab from REST API with Boot 3 + Records as DTOs and Spring Data JPA Footguns. We will keep GET /api/orders/{id}, put Spring Cache on the service, and back it with Caffeine in the same JVM. The design rule is: cache the response you already mapped, then evict it when the order changes.
The read that runs twice
The lab uses Java 17+, Spring Boot 3, Spring MVC, Spring Data JPA, and H2. The HTTP contract stays a record. Persistence stays an entity. Caching will sit on the service method that already maps between them.
Start from a focused order: id, customer, and status. Line items are useful for fetch-plan work; they hide the cache hit we need to see.
package com.geekmonks.orders.persistence;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.UUID;
@Entity
@Table(name = "purchase_orders")
public class PurchaseOrder {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String customerId;
private String status;
protected PurchaseOrder() {
// Required by JPA.
}
public PurchaseOrder(String customerId) {
this.customerId = customerId;
this.status = "CREATED";
}
public void cancel() {
this.status = "CANCELLED";
}
public UUID getId() {
return id;
}
public String getCustomerId() {
return customerId;
}
public String getStatus() {
return status;
}
}
The repository is a plain Spring Data interface. findById is the method the GET path will call on every request until we add a cache.
package com.geekmonks.orders.persistence;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PurchaseOrderRepository
extends JpaRepository<PurchaseOrder, UUID> {}
The service maps the entity to an OrderResponse record while the transaction is open, then returns the record. That mapping is the same DTO boundary as the REST post.
package com.geekmonks.orders;
import com.geekmonks.orders.api.OrderResponse;
import com.geekmonks.orders.persistence.PurchaseOrder;
import com.geekmonks.orders.persistence.PurchaseOrderRepository;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
@Service
public class OrderService {
private final PurchaseOrderRepository repository;
public OrderService(PurchaseOrderRepository repository) {
this.repository = repository;
}
@Transactional
public OrderResponse create(String customerId) {
return toResponse(repository.save(new PurchaseOrder(customerId)));
}
@Transactional(readOnly = true)
public OrderResponse findById(UUID id) {
var order = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Order not found"));
return toResponse(order);
}
private static OrderResponse toResponse(PurchaseOrder order) {
return new OrderResponse(
order.getId(),
order.getCustomerId(),
order.getStatus());
}
}
The response record is the HTTP contract and, later, the cache value. Keep it in the api package, not on the entity.
package com.geekmonks.orders.api;
import java.util.UUID;
public record OrderResponse(UUID id, String customerId, String status) {}
Keep the controller thin. It accepts and returns records; it does not talk to the repository.
package com.geekmonks.orders.api;
import com.geekmonks.orders.OrderService;
import java.util.UUID;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orders;
public OrderController(OrderService orders) {
this.orders = orders;
}
@PostMapping
ResponseEntity<OrderResponse> create() {
var response = orders.create("customer-42");
return ResponseEntity.ok(response);
}
@GetMapping("/{id}")
OrderResponse findById(@PathVariable UUID id) {
return orders.findById(id);
}
}
Turn SQL logging on so the duplicate read is visible:
spring:
jpa:
open-in-view: false
show-sql: true
logging:
level:
org.hibernate.SQL: DEBUG
Start the application:
./mvnw spring-boot:run
Create an order, then fetch it twice. The POST returns the id the GETs will reuse:
curl -sS -X POST http://localhost:8080/api/orders
Use the returned id on two identical GETs:
curl -sS http://localhost:8080/api/orders/7da2aab5-aeba-46f3-9610-39f0aa46098a
curl -sS http://localhost:8080/api/orders/7da2aab5-aeba-46f3-9610-39f0aa46098a
Both calls return the same JSON. The log still shows two selects:
select po1_0.id, po1_0.customer_id, po1_0.status
from purchase_orders po1_0
where po1_0.id=?
select po1_0.id, po1_0.customer_id, po1_0.status
from purchase_orders po1_0
where po1_0.id=?
Two GETs for an unchanged order should not mean two database round-trips. That is the whole problem this cache exists to solve.
Turn caching on
Spring Cache is an abstraction. Annotations declare when to read, write, or drop an entry. A CacheManager decides where those entries live. This lab uses Caffeine: a local, in-process cache in the application heap.
Add the cache starter and Caffeine. Spring Boot will auto-configure a Caffeine CacheManager once caching is enabled.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
Put @EnableCaching on the application class. Boot does not turn annotation caching on just because the starter is on the classpath.
package com.geekmonks.orders;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class OrdersApplication {
public static void main(String[] args) {
SpringApplication.run(OrdersApplication.class, args);
}
}
Note: @EnableCaching uses a Spring AOP proxy, the same mechanism as @Transactional. A call from another bean enters the proxy and can hit the cache. A method on this calling findById does not.
Cache the GET
@Cacheable("orders") means: look up this method’s result in the orders cache before running the method. On a miss, run findById, then store the return value. On a hit, skip the repository.
The default key is the method argument. For findById(UUID id), the cache key is the order id.
@Cacheable("orders")
@Transactional(readOnly = true)
public OrderResponse findById(UUID id) {
var order = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Order not found"));
return toResponse(order);
}
The cached value is OrderResponse, not PurchaseOrder. Caffeine configuration comes next. Why the entity must stay out of this map is covered after eviction.
A thrown ResponseStatusException is not a return value, so a missing order is not stored as a successful result. That is the behavior we want for this lab: 404s stay live reads.
Restart and repeat the two GETs. The first call still runs SQL. The second should not.
Bound it with Caffeine
An unbounded cache is a memory leak with extra steps. Caffeine needs a maximum size and a time-to-live so stale order snapshots leave the heap even if nobody updates them.
Name the cache and set a Caffeine spec. maximumSize caps entries. expireAfterWrite is the TTL after the value was stored.
spring:
cache:
type: caffeine
cache-names: orders
caffeine:
spec: maximumSize=500,expireAfterWrite=10m
jpa:
open-in-view: false
show-sql: true
logging:
level:
org.hibernate.SQL: DEBUG
org.springframework.cache: TRACE
expireAfterWrite=10m means a cached order is dropped ten minutes after it was put, even if it is still being read. Use expireAfterAccess when a hot key should stay only while callers keep hitting it. For order reads, write-expiry is the safer default: the snapshot has an age you can explain.
A @Bean CaffeineCacheManager can set the same maximumSize and expireAfterWrite in code. That bean replaces Boot’s auto-configured manager, so do not also set spring.cache.caffeine.spec and hope the two merge.
Caffeine lives in this process. Another application instance has its own heap and its own orders map. That is expected for a local cache, not a bug in @Cacheable.
Prove the second GET is a hit
Create an order, then GET it twice with TRACE logging on org.springframework.cache.
The first GET should miss, run SQL, and put the record:
Computed cache key [7da2aab5-aeba-46f3-9610-39f0aa46098a] for operation ...
Cache entry for key '7da2aab5-aeba-46f3-9610-39f0aa46098a' not found in cache 'orders'
select po1_0.id, po1_0.customer_id, po1_0.status from purchase_orders po1_0 where po1_0.id=?
The second GET should hit and skip SQL:
Cache entry for key '7da2aab5-aeba-46f3-9610-39f0aa46098a' found in cache 'orders'
The JSON is unchanged:
{
"id": "7da2aab5-aeba-46f3-9610-39f0aa46098a",
"customerId": "customer-42",
"status": "CREATED"
}
If the second GET still prints a select, the annotation is not on the proxied call. Check @EnableCaching, that the controller calls OrderService rather than a self-invocation, and that the method is public.
Evict when the order changes
A cache that never expires on write is a stale-read machine. When status changes, the orders entry for that id must leave the cache before the next GET.
Add a cancel use case on the service. @CacheEvict drops the entry keyed by id after the database update succeeds.
@CacheEvict(value = "orders", key = "#id")
@Transactional
public OrderResponse cancel(UUID id) {
var order = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Order not found"));
order.cancel();
return toResponse(order);
}
The default beforeInvocation is false, so eviction runs after the method returns. If cancel throws, the cache entry stays, which matches the database: the order was not cancelled.
Expose it as POST /api/orders/{id}/cancel:
@PostMapping("/{id}/cancel")
OrderResponse cancel(@PathVariable UUID id) {
return orders.cancel(id);
}
Warm the cache with a GET, cancel the order, then GET again:
curl -sS http://localhost:8080/api/orders/7da2aab5-aeba-46f3-9610-39f0aa46098a
curl -sS -X POST \
http://localhost:8080/api/orders/7da2aab5-aeba-46f3-9610-39f0aa46098a/cancel
curl -sS http://localhost:8080/api/orders/7da2aab5-aeba-46f3-9610-39f0aa46098a
The cancel response and the following GET both show CANCELLED:
{
"id": "7da2aab5-aeba-46f3-9610-39f0aa46098a",
"customerId": "customer-42",
"status": "CANCELLED"
}
The log should show a cache eviction, then a cache miss and a new select on the next GET. That miss is correct: the old CREATED snapshot is gone.
If you update the order and forget to evict, the next GET will keep serving the old record until TTL. Size and TTL are a backstop, not a substitute for eviction on the write path. allEntries = true is the blunt option after a bulk rewrite; prefer a keyed evict when you know the id.
Put when you already have the new value
@CacheEvict deletes. The next GET pays for a database read. @CachePut always runs the method and then stores the return value under the key. Use it when the write path already returns the fresh DTO.
@CachePut(value = "orders", key = "#id")
@Transactional
public OrderResponse cancel(UUID id) {
var order = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Order not found"));
order.cancel();
return toResponse(order);
}
After this cancel, a GET can hit Caffeine without another select. The method still talks to the database — @CachePut does not skip work the way @Cacheable does. It refreshes the cache as a side effect of a write you were going to do anyway.
Choose one policy per write method. Evict if other fields might be loaded by a different query than the one this method mapped. Put if this method’s OrderResponse is exactly what findById should return next.
@Cacheable miss -> run method -> store result
hit -> return cached value, skip method
@CachePut always run method -> store result
@CacheEvict after success -> drop key (or all entries)
Do not cache a live JPA entity
It is tempting to annotate repository.findById and store PurchaseOrder. Do not.
A JPA entity is a persistence-identity object. It may be a Hibernate proxy. It may have lazy associations that only work inside an open persistence context. It is mutable. Caffeine will hand the same instance to every caller on that JVM.
If you cache the entity:
- a later GET can touch a lazy collection after the transaction closed and throw
LazyInitializationException, - one request can mutate the cached instance and change what the next request sees without going through
cancel, - JSON serialization starts depending on Hibernate internals instead of the API record.
That is the same boundary already argued in Spring Data JPA Footguns: entities stay inside the persistence transaction; records cross the API. Caching does not change the rule. It makes breaking it more expensive, because the broken object now outlives the request.
Cache OrderResponse, never a managed PurchaseOrder. Map first, then store. The REST records post is the DTO side of that handoff: REST API with Boot 3 + Records as DTOs.
Records are a good cache value when they are immutable and their collections are copied. If a future OrderResponse grows a List, keep the compact-constructor List.copyOf so callers cannot mutate the cached list.
What this cache is not
Caffeine here is an in-process map with eviction. It is not a cluster-wide store, and it is not a distributed invalidation bus.
Two instances of the orders service each have their own orders cache. Cancel on instance A does not evict instance B, which can serve CREATED until TTL fires or that instance handles a write. For this lab that trade-off is acceptable: we are removing duplicate SQL on one process. A shared store is a different cache manager and a different post.
Cache cheat sheet
Enable:
spring-boot-starter-cache + caffeine
@EnableCaching on a @Configuration
CacheManager: Boot auto-config or a CaffeineCacheManager bean
Read:
@Cacheable("orders") on the service method that returns a DTO
key defaults to the method arguments (the order id)
Write:
@CacheEvict(value = "orders", key = "#id") after a successful change
@CachePut when the write already returns the next GET body
Caffeine:
maximumSize=500
expireAfterWrite=10m (TTL from insert)
local heap, per JVM
Do:
cache OrderResponse
evict or put on cancel/update
keep @Cacheable on the Spring proxy (public, other-bean call)
Do not:
cache PurchaseOrder / Hibernate proxies
rely on TTL instead of eviction for correctness
expect another instance to see this JVM's puts and evicts
Wrap-up
Without a cache, GET /api/orders/{id} is a repository call every time, including the second identical request. @EnableCaching plus @Cacheable("orders") makes that second call a Caffeine hit. maximumSize and expireAfterWrite keep the heap bounded. @CacheEvict — or @CachePut when you already hold the new DTO — keeps the snapshot honest after cancel.
Put the annotations on the service, cache the record you already map for JSON, and leave JPA entities inside their transaction. That is enough to stop querying for the same order twice on one JVM.