The previous lesson ended with a motto: unnecessary work is eliminated first. The stations endpoint went from 2.4 seconds to 91 milliseconds by removing 42 queries and adding an index, with no cache and no extra machine. That order is non-negotiable, and it is also the frame for this lesson: a cache is the lever you use when the work is already minimal and it still gets repeated.

Because that is exactly what happens in Ribalta. The station catalogue is queried about a hundred times a second at peak and it changes when the council opens a new station, that is, four times a year. The fares are consulted on every amount calculation and are revised once a year. Running a perfectly indexed 3-millisecond query a hundred times a second to always get the same answer is minimal work repeated: the textbook case for a cache.

We will look at Spring's cache abstraction and why the code does not depend on the provider, the annotations with all their attributes and their traps —keys, condition and unless, the proxy—, Caffeine for a local cache and Redis for a distributed one, and then the part that really separates a useful cache from a source of bugs: invalidation. We will finish by measuring and testing it, because a cache whose hit rate you do not know is a decision taken blind.

Contents

  1. When a cache is the right answer
  2. Which CicloUrbana data deserves a cache
  3. Spring's cache abstraction
  4. The annotations and their attributes
  5. Keys: the part that produces the most errors
  6. condition, unless and the null problem
  7. The proxy trap, again
  8. Providers: which to choose and why
  9. Caffeine: CicloUrbana's local cache
  10. Redis: the distributed cache
  11. Local versus distributed
  12. Invalidation: the hard part
  13. Cache stampede
  14. Hibernate's second-level cache
  15. The cache that costs nothing: ETag and Cache-Control
  16. Measuring the cache
  17. Testing cached code
  18. Common Mistakes and Tips
  19. Exercises

  1. When a cache is the right answer

A cache stores the result of an expensive operation so it does not have to be repeated. That sounds harmless, and it is the reason caches get abused: a badly placed cache does not fail, it lies. It returns correct data that is no longer true, and it does so intermittently and in a way that is hard to reproduce.

The rule that governs the whole lesson is this: fix the query first, then cache. If an endpoint takes 2 seconds because of an N+1, a cache will bring it down to 5 milliseconds and the problem will still be there, waiting for the first cache miss, the first deployment, the first new record. Worse still: you will have hidden the symptom that would have led you to the cause. A cache over optimised code multiplies a real improvement; a cache over bad code buys silence.

With that clear, a piece of data is a good candidate when it meets all three conditions at once:

Condition Why it matters How to check it
It is read many times With no repetition there is nothing to save Per-endpoint invocation metrics (09-03)
It is written rarely Every write forces an invalidation; with frequent writes the cache never fills up The real rate of UPDATEs on that table
It tolerates being slightly stale Every cache serves data from n seconds ago A business decision, not a technical one

The third is the one you have to negotiate with whoever knows the domain, not settle in the editor. And there is an implicit fourth condition: the result has to fit. Caching a full paginated listing of three million rentals is not a cache, it is a second, worse-built database.

  1. Which CicloUrbana data deserves a cache

Data Cache? Reason TTL
Station catalogue (name, address, capacity) Yes Thousands of reads per minute, changes quarterly 10 min
Ribalta's fares (standard, student, senior) Yes Consulted on every calculation, changes once a year 1 h
User record for authorisation Yes, carefully Heavily read; invalidate on deactivation or role change 5 min
The municipality's zones and polygons Yes Practically immutable 24 h
Available bikes per station No Changes every second; 30-second-old data sends the citizen to an empty station —
A user's rental in progress No It is the state that governs the 04-08 business rule —
Calculated amount for a specific rental No Used once only; there is no repetition —
Payment gateway response (07-06) Never Caching a charge is a mistake of a different category —

The two "no"s in the middle are the lesson of this section. Real-time availability and caching are incompatible, and the temptation is enormous because it is precisely the most-queried endpoint. The right answer in that case is not to cache the bike count: it is to make the query cheap (the composite index from 09-01) and, if more were needed, to keep an up-to-date counter in the stations table itself. The practical distinction: cache the catalogue, never the state.

  1. Spring's cache abstraction

Spring does not implement a cache: it defines an abstraction —three interfaces and an aspect— and delegates to a real provider.

flowchart LR
    A["@Cacheable on StationService"] --> B[AOP proxy<br/>CacheInterceptor]
    B --> C[CacheManager]
    C --> D["Cache 'stations'"]
    D --> E1[Caffeine<br/>local memory]
    D --> E2[Redis<br/>distributed]
    D --> E3[ConcurrentMapCache<br/>default]
    B -->|cache miss| F[Real method -> DB]

The pieces: Cache is the contract of a specific cache (get, put, evict, clear); CacheManager locates caches by name; and the CacheInterceptor, an aspect just like the one behind @Transactional (04-07), decides before each invocation whether to call the method or return what is stored.

The practical consequence is the one that matters: your code never mentions Caffeine or Redis. You annotate @Cacheable("stations") and you switch provider by touching one dependency and one configuration class. In CicloUrbana we will start with Caffeine and move to Redis when we scale to several instances, without touching a line of StationService.

It is switched on with an annotation on a configuration class:

package com.ciclourbana.common.cache;

@Configuration
@EnableCaching     // without this, the annotations do absolutely nothing
public class CacheConfig { }

Forgetting @EnableCaching is the first classic mistake, and it is silent: the code compiles, the tests pass and the cache simply does not exist.

  1. The annotations and their attributes

Annotation What it does Key attributes Use in CicloUrbana
@Cacheable If there is an entry it returns it without running the method; otherwise it runs and stores value/cacheNames, key, condition, unless, sync Reading a station or the catalogue
@CachePut Always runs the method and stores the result The same as @Cacheable Updating a station and refreshing its entry
@CacheEvict Removes entries key, allEntries, beforeInvocation Deleting a station
@Caching Groups several annotations of the same type or mixed cacheable, put, evict An operation touching two caches
@CacheConfig Class-level defaults cacheNames, keyGenerator Avoiding repeating "stations" on every method

The difference between @Cacheable and @CachePut is what confuses people most and it is simple: @Cacheable may skip the method; @CachePut never does. That is why @CachePut is the annotation for updates and @Cacheable the one for reads. Putting them together on the same method is almost always a design mistake.

@CacheEvict has two attributes with consequences. allEntries = true empties the entire cache rather than one key: it is the hammer, correct after a bulk import and excessive when modifying a single station. And beforeInvocation decides when the removal happens: the default is false, so the entry is removed after the method completes successfully and an exception leaves the cache untouched —normally what you want—, whereas with true it is removed beforehand, which suits cases where a failure halfway through could leave the cache holding false data.

@Service
@CacheConfig(cacheNames = "stations")     // default for the whole class
public class StationService {

    @Cacheable(key = "#id")
    public StationResponse getById(Long id) { /* query to the DB */ }

    @CachePut(key = "#result.id")            // stores the already-updated result
    public StationResponse update(Long id, UpdateStationRequest request) { }

    @CacheEvict(key = "#id")
    public void delete(Long id) { }

    @CacheEvict(allEntries = true)           // after a bulk import
    public void importFromCouncil(List<StationCsv> rows) { }
}

  1. Keys: the part that produces the most errors

Each entry is stored under a key. Without key, Spring uses SimpleKeyGenerator, whose rules are easy to remember and dangerous to assume: no arguments → SimpleKey.EMPTY (a single entry for the whole method); one argument → the argument itself; several arguments → a SimpleKey combining them.

The three problems that default behaviour produces:

The method with no arguments. @Cacheable("stations") public List<StationResponse> findAll() stores a single entry under SimpleKey.EMPTY. It works, but if tomorrow an activeOnly parameter is added, the key changes by itself and the behaviour with it.

The object as an argument. If the argument is a record, its equals/hashCode are correct and the key works. If it is a JPA entity with equals based on the id —or worse, with no equals—, the key will be the object's identity and there will never be a hit: every request creates a new instance. It is the most frustrating failure, because the cache looks configured and its hit rate is zero.

The Pageable. @Cacheable public Page<X> list(Pageable p) uses the Pageable as the key. Technically it works —PageRequest implements equals— but it generates one entry per combination of page, size and sort order: hundreds of nearly identical entries. Caching paginated listings almost never pays off.

The solution is to declare the key with SpEL:

@Cacheable(cacheNames = "stations", key = "#id")                    // one argument
public StationResponse getById(Long id) { }

@Cacheable(cacheNames = "fares", key = "#type.name() + ':' + #minutes")   // several
public BigDecimal calculate(FareType type, long minutes) { }

@Cacheable(cacheNames = "users", key = "#request.email")   // a property, not the object
public UserResponse byEmail(FindUserRequest request) { }

@CachePut(cacheNames = "stations", key = "#result.id")   // #result: only in @CachePut
public StationResponse update(UpdateStationRequest request) { }

Inside SpEL you have #argumentName —which requires compiling with -parameters, already enabled by spring-boot-starter-parent—, #p0/#a0 by position, #root.methodName, #root.target and #result in the annotations evaluated after the invocation.

When the key logic repeats itself, a custom generator avoids duplicating it:

@Component("stationKey")
public class StationKeyGenerator implements KeyGenerator {
    @Override
    public Object generate(Object target, Method method, Object... args) {
        return method.getName() + ':' +
               Arrays.stream(args).map(String::valueOf).collect(Collectors.joining(":"));
    }
}

It is used with @Cacheable(cacheNames = "stations", keyGenerator = "stationKey"). key and keyGenerator are mutually exclusive: declaring both is a startup error.

One rule that saves incidents: the key must be a small, immutable value with correct equals/hashCode —a Long, a String, a simple record—. If in doubt, build an explicit String.

  1. condition, unless and the null problem

Both attributes filter, but at different moments and that difference is the key:

condition unless
When it is evaluated Before the invocation After the invocation
What it decides Whether the cache is consulted and written Whether the store is discarded
#result available No Yes
Semantics "cache if..." "do not cache if..."
@Cacheable(cacheNames = "stations", key = "#id",
           condition = "#id != null && #id > 0",             // do not cache absurd requests
           unless = "#result == null || !#result.active()")  // do not cache what is useless
public StationResponse getById(Long id) { }

The classic mistake: caching absence. If getById(999) returns null or Optional.empty() and it gets stored, we have two problems at once. The first, that when station 999 really is created, the service will keep saying for the whole TTL that it does not exist. The second, subtler one: with Optional, @Cacheable stores the empty Optional as a perfectly valid value, so the cache does hit and returns "does not exist" without querying. Which behaviour you want depends on the case —caching absences is a legitimate defence against an attack requesting non-existent identifiers—, but it has to be a conscious decision, and it is expressed with unless = "#result == null" or with a short, dedicated TTL for that cache.

Be careful with the evaluation too: condition and unless are SpEL evaluated on every invocation. An expression calling an expensive method turns the saving into a cost.

  1. The proxy trap, again

This is the third time it has come up in the course, with @Transactional (04-07) and with @Async (07-03), and the mechanism is identical: cache annotations are applied by a proxy, and an internal call does not go through the proxy.

@Service
public class StationService {

    public List<StationResponse> listActive() {
        return findAll().stream()                     // this.findAll(): NO CACHE
                .filter(StationResponse::active).toList();
    }

    @Cacheable("stations")
    public List<StationResponse> findAll() { /* query to the DB */ }
}

listActive() goes to the database every single time. Neither the compiler nor Spring warns you, and the only signal is an anomalously low hit rate: which is why section 16 is not optional. The ways out, in order of preference: move the cached method to another bean —the right one, because a cache usually marks a responsibility boundary—; inject the bean into itself with @Lazy (ugly but explicit); or use AopContext.currentProxy(), which requires @EnableAspectJAutoProxy(exposeProxy = true) and is the last resort.

And two conditions people forget: the method must be public —a private, protected or package-private one is silently ignored— and not final, because a CGLIB proxy cannot override it.

  1. Providers: which to choose and why

Spring Boot autoconfigures the provider according to what it finds on the classpath:

Provider Scope TTL Statistics When to use it
ConcurrentMapCache (default) Local No No Tests and prototypes only
Caffeine Local Yes Yes (recordStats) One instance, or data tolerant of divergence
Redis Distributed Yes Partial Several instances needing coherence
Hazelcast Distributed, inside the JVM Yes Yes Data grid, with no separate server
EhCache 3 (JSR-107) Local, with overflow to disk Yes Yes Legacy; very large caches

Why the default provider is not good enough for production. ConcurrentMapCache is a ConcurrentHashMap: it has no expiry and no maximum size. Everything that goes in stays for ever, so two things end up happening: the data goes stale indefinitely and the memory grows until the OutOfMemoryError. It is a demonstration cache, and Spring only uses it because it needs a default. If you see ConcurrentMapCacheManager at production startup, a dependency is missing.

The selection criterion, in one line: a single instance and data that tolerates seconds of divergence → Caffeine; several instances that must see the same thing → Redis. Nothing more, and in particular: you do not pick Redis "just in case", because it introduces a network dependency in the read path and a millisecond of latency where Caffeine takes nanoseconds.

  1. Caffeine: CicloUrbana's local cache

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

With the dependency present, Spring Boot configures CaffeineCacheManager with nothing more. Property-based configuration:

spring.cache:
  type: caffeine
  cache-names: stations,fares,zones           # created at startup
  caffeine.spec: maximumSize=1000,expireAfterWrite=10m,recordStats

What each spec term means. maximumSize=1000 limits the number of entries: when it is exceeded, Caffeine evicts using its Window TinyLFU algorithm, which combines frequency and recency and gets it right considerably more often than a classic LRU. expireAfterWrite=10m expires the entry ten minutes after it is written, read or not —the classic TTL—; its alternative, expireAfterAccess, counts from the last access, with the risk that a heavily consulted item never expires, so for data that changes you use expireAfterWrite. And recordStats enables hit and miss counting: without it, section 16 has nothing to measure and /actuator/caches will not say much.

A single spec applies to every cache, which is rarely what you want: the fares can take an hour and the catalogue ten minutes. To tune per cache you declare the manager:

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    CacheManager cacheManager() {
        SimpleCacheManager manager = new SimpleCacheManager();
        manager.setCaches(List.of(
                cache("stations", 1_000, Duration.ofMinutes(10)),
                cache("fares",       50, Duration.ofHours(1)),
                cache("zones",      200, Duration.ofHours(24))));
        return manager;
    }

    private CaffeineCache cache(String name, long size, Duration ttl) {
        return new CaffeineCache(name, Caffeine.newBuilder()
                .maximumSize(size).expireAfterWrite(ttl)
                .recordStats()                 // essential for the 09-03 metrics
                .build());
    }
}

How you tune it. The size is chosen from the number of distinct items that are actually queried —Ribalta has 40 stations, so maximumSize=1000 is more than enough and deliberately so: better spare capacity than eviction—. And the TTL comes from the business question in section 1: how many seconds can a citizen see a station's old name? If the answer is "ten minutes", that is the TTL; if it is "none", it is not a candidate for a TTL cache but for explicit invalidation.

  1. Redis: the distributed cache

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring:
  cache.type: redis
  data.redis:
    host: ${REDIS_HOST:localhost}
    port: 6379
    timeout: 500ms              # fail fast: the cache must not block the request
    lettuce.pool.max-active: 16

That much already works, but the default serialisation is JDK —binary, unreadable and fragile against any class change—. The configuration CicloUrbana actually uses:

@Bean
RedisCacheConfiguration baseConfiguration() {
    ObjectMapper mapper = JsonMapper.builder()
            .addModule(new JavaTimeModule())                       // LocalDateTime, Instant
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .activateDefaultTyping(BasicPolymorphicTypeValidator.builder()
                    .allowIfSubType("com.ciclourbana.").build(),    // only our classes
                    ObjectMapper.DefaultTyping.NON_FINAL)
            .build();
    return RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues()                            // section 6
            .serializeValuesWith(SerializationPair.fromSerializer(
                    new GenericJackson2JsonRedisSerializer(mapper)));
}

@Bean
RedisCacheManagerBuilderCustomizer ttlPerCache() {          // a different TTL per cache
    return builder -> builder
            .withCacheConfiguration("fares", baseConfiguration().entryTtl(Duration.ofHours(1)))
            .withCacheConfiguration("zones", baseConfiguration().entryTtl(Duration.ofHours(24)));
}

The classic serialisation problem. It is the first stone everybody trips over, and it has three faces. The first: without JavaTimeModule, a LocalDateTime blows up with Java 8 date/time type not supported by default. The second: even if it does not blow up, if it is serialised as [2026,8,31,7,42] instead of ISO-8601, deserialising in another version of the application will fail —hence WRITE_DATES_AS_TIMESTAMPS disabled—. And the third, the worst: without type information, a List<StationResponse> deserialises as List<LinkedHashMap> and a ClassCastException fires somewhere entirely unrelated. That is why typing is enabled, and always with a validator restricting the permitted packages: open default typing over data an attacker can influence is a well-known deserialisation vulnerability.

One tip that saves hours: if the cached value changes shape, change the cache name too (stations → stations-v2). During a rolling deployment (08-04) two versions of the application coexist reading the same Redis, and an entry written by the new one may be unreadable to the old one.

Redis in the 07-04 docker-compose.yml, alongside PostgreSQL:

  redis:
    image: redis:7-alpine
    container_name: ciclourbana-redis
    command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
    ports: ["6379:6379"]
    networks: [ciclourbana-network]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 5

--maxmemory-policy allkeys-lru is the right policy for a cache: when Redis fills its memory, it evicts the least used keys instead of rejecting writes. The default policy (noeviction) would be the right one if Redis were the source of truth; since here it is a cache, allkeys-lru.

And to test it properly, a real container with Testcontainers (06-05):

@SpringBootTest
@Testcontainers
class RedisCacheIT {

    @Container
    static final GenericContainer<?> REDIS =
            new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @DynamicPropertySource
    static void properties(DynamicPropertyRegistry reg) {
        reg.add("spring.data.redis.host", REDIS::getHost);
        reg.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
    }
}

  1. Local versus distributed

Local cache (Caffeine) Distributed cache (Redis)
Access latency Nanoseconds (same JVM) ~0.5-2 ms (network round trip)
Coherence between instances None: each one has its own copy A single shared copy
Invalidation Local only: the others never find out Global and immediate
At startup Empty: it has to be warmed Already populated by the other instances
Memory Consumes the application's heap Outside the process
Extra point of failure No Yes: you have to degrade gracefully
Operational cost Zero One more service to maintain and watch

Why a local cache stops being innocent once you scale. With one instance, @CacheEvict removes the entry and the next reader sees the new data. With three instances behind the load balancer (08-04), the update request reaches one of them: that one removes its copy and the other two keep serving the old name for the whole TTL. The result is the worst kind of bug: the citizen refreshes the screen and sees the new name, the old one and the new one again, depending on which instance they land on. And it is not reproducible locally.

There are three valid answers. Short TTLs and accepting the divergence, if the business tolerates it —ten minutes of an old name kills nobody—. Redis, if it must look the same everywhere. Or a local cache with broadcast invalidation over a message channel (Redis pub/sub or the 07-05 bus), which gives you Caffeine's latency with Redis's coherence in exchange for complexity. CicloUrbana starts with the first and moves to the second for the user cache, where a stale authorisation really does matter.

  1. Invalidation: the hard part

Phil Karlton summed it up: "there are only two hard things in computer science: cache invalidation and naming things". It is a joke with an exact truth in it: storing is trivial, knowing when what you stored stopped being true is the whole problem.

There are two strategies, and they are not mutually exclusive:

TTL (expiry) Explicit invalidation
How it works The entry dies on its own after a while The code removes the entry when the data changes
Freshness Staleness bounded by the TTL Immediate
Complexity None You have to remember on every write path
Risk Old data for the length of the TTL Forgetting a path → old data for ever
When Tolerant data; always, as a safety net Data that must be seen straight away

CicloUrbana's recommendation: both. Explicit invalidation so the change is visible instantly, and a TTL always in place as the net under the trapeze, because sooner or later somebody will add a write path that does not invalidate —a Flyway migration, a scheduled task, a manual UPDATE by the council—.

The naive version:

@Transactional
@CacheEvict(cacheNames = "stations", key = "#id")
public StationResponse update(Long id, UpdateStationRequest request) { }

And here is the mistake almost nobody sees the first time. The cache annotation is evaluated around the method, but the transaction commits... also around the method, and in an order you do not control. With the default configuration, the CacheInterceptor may remove the entry before the transaction commits. That opens a window of milliseconds in which the cache is empty and the database still holds the old value: if another request reads at that instant, it repopulates the cache with the old data and leaves it there for the whole TTL. Worse still if the transaction ends in a rollback: you have invalidated because of a change that never happened, which is harmless, but the previous case is not harmless at all.

The correct solution is to invalidate after the commit, with the event mechanism from 04-07:

// 1. The service publishes an event inside the transaction
@Transactional
public StationResponse update(Long id, UpdateStationRequest request) {
    Station station = stationRepository.findById(id).orElseThrow(...);
    station.update(request.name(), request.address(), request.capacity());
    publisher.publishEvent(new StationUpdated(id));
    return mapper.toResponse(station);
}

// 2. A separate component invalidates once the commit has happened
@Component
public class StationCacheInvalidator {

    private final CacheManager cacheManager;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onStationUpdated(StationUpdated event) {
        Optional.ofNullable(cacheManager.getCache("stations"))
                .ifPresent(cache -> cache.evict(event.id()));
    }
}

Three advantages over the annotation. You never invalidate because of a change that failed to commit. There is no repopulation window with old data, because by the time the entry is removed the database already holds the new value. And the responsibilities stay separated: the domain service publishes that something changed and does not know a cache exists, which is exactly the right relationship between the two.

Two more cases. When creating a station there is no entry to remove, but there is an aggregated listing that has gone stale: @CacheEvict(cacheNames = "stations", allEntries = true) or, better, a separate cache for the full catalogue. And in bulk imports, invalidating entry by entry is absurd: you empty the whole cache once at the end.

  1. Cache stampede

The station catalogue entry expires at 8:03. At that instant there are 200 requests in flight: all 200 miss at once and all 200 run the query at once. That is the cache stampede, and its signature on the graph is a clean spike of latency and pool connections every expireAfterWrite, with the rest of the time quiet.

Three mitigations, from the simplest to the best:

Locking (sync = true). @Cacheable(cacheNames = "stations", key = "#id", sync = true) makes only one thread compute the value while the others wait for it to finish. It solves the stampede in one line. Its limits: only some providers support it —Caffeine does, Redis does not natively—, it is a per-instance lock, and it is incompatible with unless.

Background refresh (refreshAfterWrite). Specific to Caffeine: once that time has passed, the first request to arrive returns the old value immediately and triggers the reload on another thread. No request ever waits. Caffeine.newBuilder().refreshAfterWrite(Duration.ofMinutes(5)).expireAfterWrite(Duration.ofMinutes(30)).build(key -> load(key)) combines the two: refresh at 5 minutes, hard expiry at 30 in case the reload keeps failing. It is the best option for the Ribalta catalogue.

Jitter. If every entry is written at the same time —at startup, or after a flush— they all expire at the same time. Adding a random variation to the TTL (10m ± 20 %) spreads them out over time. In Caffeine you get it with expireAfter(Expiry); in Redis, with a TTL computed at write time.

And a fourth one that is architectural: warm the cache at startup with an ApplicationRunner loading the 40 stations. Cheap, and it stops the first wave of traffic after a deployment from paying for every miss.

  1. Hibernate's second-level cache

This is an entity cache, inside the ORM, and it operates on a different plane. The first-level one is the persistence context and lasts as long as the transaction (04-07): it always exists and is not configured. The second-level one is shared by every session and has to be enabled explicitly (hibernate.cache.use_second_level_cache, a provider such as EhCache or Infinispan, and @Cache on each entity), on top of which sits the query cache for query results.

Application cache (Spring) Hibernate second level
What it stores The method result: already-mapped DTOs Entities by identifier, in disassembled form
What it saves Query and mapping and logic Only the query by id
Visibility Explicit: you see it in the code Implicit: you see it nowhere
Invalidation You control it Hibernate manages it... if everything goes through Hibernate
Risk Your own mistakes Stale data with modifying @Query, Flyway or external SQL

Why the course prefers the application cache. Because it is explicit: reading @Cacheable("stations") tells you there is a cache and where it is. The second-level one is invisible in the code, and when it produces stale data the investigation is long because nobody remembers it was enabled. It also saves less: the application cache skips the MapStruct mapping (03-05) and the DTO construction as well, whereas the second-level one only avoids the SQL round trip —and it does not even do that for queries that are not by id, unless you also enable the query cache, which is notoriously delicate—.

When does it make sense? In a domain with many immutable reference entities read by id from many places, and with every write going through Hibernate. If Flyway modifies data (04-08) or an external process touches the database, they are outside its control and it will serve false data without warning.

  1. The cache that costs nothing: ETag and Cache-Control

Before caching on the server, it is worth remembering that the fastest request is the one that is never made. With the HTTP headers from 03-03:

@GetMapping("/{id}")
public ResponseEntity<StationResponse> getById(@PathVariable Long id) {
    StationResponse station = stationService.getById(id);
    return ResponseEntity.ok()
            .eTag("\"" + station.version() + "\"")               // the @Version from 04-03
            .cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
            .body(station);
}

Cache-Control: max-age=300, public authorises the browser, the mobile app and any intermediate proxy to reuse the response for five minutes without asking: zero requests to the server. And the ETag, either with ShallowEtagHeaderFilter or computed by hand from the @Version, lets the client ask with If-None-Match and receive a two-hundred-byte 304 Not Modified: it saves the serialisation and the bandwidth, though not the server's work.

The combination of the three layers is what pays: Cache-Control avoids the request, the ETag avoids the body and @Cacheable avoids the query. With one security warning: cachePublic() only on non-personalised responses. Marking a response that depends on the authenticated user as public makes a proxy serve one citizen's data to another; that is what cachePrivate() is for.

  1. Measuring the cache

A cache with no metrics is an unverified decision. The two questions are is it hitting? and is it growing out of control?

/actuator/caches (07-01) lists the caches and their managers, useful for confirming that the ones you think exist really do. But what matters are the statistics, which require recordStats. With Caffeine and recordStats enabled, Micrometer automatically publishes:

Metric What it measures What to watch
cache.gets{result="hit"} Hits It should dominate by a wide margin
cache.gets{result="miss"} Misses A high ratio = a useless cache or a badly chosen key
cache.puts Writes If ≈ misses, every miss repopulates: normal
cache.evictions Evictions by size Constant = maximumSize too small
cache.size Current entries Pinned to the maximum = review the sizing

The hit rate is hits / (hits + misses). As a rough guide: above 90 % the cache is doing its job; between 50 % and 90 % you should review the TTL or the size; below 20 % the cache is costing more than it saves and almost always indicates one of the three causes already seen —a badly built key, self-invocation bypassing the proxy, or data that simply does not repeat—.

For these metrics to exist the cache has to be registered with the MeterRegistry, something autoconfiguration does on its own if the CacheManager is the autoconfigured one; if you declare it yourself (section 9), add a @Bean of type CacheMetricsRegistrar or mark the caches with CaffeineCacheMetrics.monitor(registry, cache, "stations"). All the instrumentation is explained in 09-03 and drawn in Grafana in 09-04, where a hit-rate-per-cache panel is one of the quickest ways to spot a regression.

  1. Testing cached code

Unit tests do not see the cache, and this surprises a lot of people. A StationServiceTest that instantiates the service with new and passes it a Mockito mock (06-03) works with the real object, with no proxy: @Cacheable does nothing. That is correct and indeed desirable —the unit test checks the logic, not the infrastructure— but it means the cache can only be tested in integration.

@SpringBootTest
@AutoConfigureCache                       // forces a real CacheManager, not the test one
class StationServiceCacheIT {

    @Autowired StationService stationService;
    @Autowired CacheManager cacheManager;
    @MockitoBean StationRepository stationRepository;   // the collaborator, mocked

    @BeforeEach
    void clearCaches() {   // a cache is shared state: the tests have to be isolated
        cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
    }

    @Test
    void secondReadDoesNotHitTheDatabase() {
        given(stationRepository.findById(1L)).willReturn(Optional.of(aStation()));

        stationService.getById(1L);
        stationService.getById(1L);

        verify(stationRepository, times(1)).findById(1L);   // the cache assertion
        assertThat(cacheManager.getCache("stations").get(1L)).isNotNull();
    }

    @Test
    void updateEvictsTheEntry() {
        stationService.getById(1L);
        stationService.update(1L, new UpdateStationRequest("Main Square II", 26));
        assertThat(cacheManager.getCache("stations").get(1L)).isNull();
    }
}

What makes this test valid is verify(..., times(1)): it proves the method was not executed, which is the operational definition of "the cache worked". And the second test covers what really breaks over time, which is not storing but invalidating.

Two warnings. Clear the caches between tests —a cache is shared state and produces the worst kind of test, the order-dependent one—. And disable the cache in the test profile when it gets in the way, with spring.cache.type: none, which Spring recognises and which turns the annotations into no-ops without touching the code.

Common Mistakes and Tips

Forgetting @EnableCaching. Everything compiles, nothing is cached, no warning. Check it with the integration test from section 17.

Caching to paper over an N+1, or caching real-time data. You fix the query first (09-01): a cache over bad code buys silence, not performance, and hides the symptom that led to the cause. And the available bikes per station change every second, so half-minute-old data sends the citizen to an empty station: cache the catalogue, never the state.

Self-invocation. A cached method called from another in the same class does not go through the proxy and never hits. The signal is a hit rate close to zero: which is why they are measured.

Using a JPA entity as a key or as a value. As a key it almost never hits; as a value it drags along lazy collections that blow up when serialised into Redis or read outside the session. Cache DTOs.

Invalidating inside the transaction. It opens a window in which another request repopulates the cache with the old value and leaves it there for the whole TTL. You invalidate in AFTER_COMMIT.

Using ConcurrentMapCache in production. No TTL and no maximum size: eternally stale data and memory that only goes up. If ConcurrentMapCacheManager shows up in the startup log, a dependency is missing.

A local cache with several instances, without realising. It works locally and in production produces data that flickers depending on which instance answers. Decide on a short TTL, on Redis or on broadcast invalidation, but decide.

Tip: always set a TTL, even if you invalidate explicitly, as a safety net for the day somebody adds a write path that does not invalidate. Name your caches as constants (public static final String CACHE_STATIONS = "stations";) and use them in annotations and invalidators, because a misspelled string silently creates a new, empty cache. And review the hit rate a month after deploying: a cache below 20 % should be deleted, since it costs memory, complexity and the risk of stale data in exchange for nothing.

Exercises

Exercise 1: deciding what to cache

The council asks for four endpoints to be sped up: (a) GET /api/v1/fares, the catalogue of the three fares, 400 requests/minute, changes once a year; (b) GET /api/v1/stations/{id}/availability, the number of free bikes, 6,000 requests/minute, changes every second; (c) GET /api/v1/users/{id}, the user record with their fare type and whether they are active, 2,000 requests/minute, changes when the citizen edits their profile or the council deactivates them; (d) GET /api/v1/rentals/{id}/amount, the calculated amount for a specific rental, 900 requests/minute. Decide for each one whether it is cached, with which provider, what TTL and what invalidation strategy, and justify the "no"s.

Exercise 2: finding the three mistakes

This code does not work the way its author thinks. Find the three defects, explain the symptom of each and write the corrected version.

@Service
public class FareService {

    @Cacheable("fares")
    public List<FareResponse> list() { return fareRepository.findAll().stream()... }

    public FareResponse findByType(FareType type) {
        return list().stream().filter(f -> f.type() == type).findFirst().orElse(null);
    }

    @Transactional
    @CacheEvict(cacheNames = "fares", key = "#fare.type")
    public void update(Fare fare) { fareRepository.save(fare); }
}

Exercise 3: the flickering cache

CicloUrbana has been scaled to three instances on Kubernetes (08-04) keeping Caffeine with expireAfterWrite=30m. The council renames "North Station" to "North Station - Interchange" and calls to say the app "sometimes shows the new name and sometimes the old one". Explain exactly what is happening, propose three solutions with their trade-offs, pick one and write the configuration and code needed.

Solutions

Solution 1.

(a) Fares: yes, the ideal case. It meets all three conditions comfortably —heavily read, almost never written, and an old fare for an hour does no harm because fare changes are announced in advance—. Caffeine, maximumSize=50, expireAfterWrite=1h, plus @CacheEvict(allEntries = true) in the update's AFTER_COMMIT. With just three fares and an irrelevant divergence between instances, Redis contributes nothing.

(b) Availability: no. It fails the decisive condition, tolerance of stale data: the point of the endpoint is to know whether there is a bike right now, and 30-second-old data sends the citizen to an empty station —the worst possible product failure, because it does not look like a bug, it looks like a service that lies—. The right answer is to make the query cheap: the composite index on (station_id, status) from 09-01 brings it under 1 ms. If even that were not enough, the solution is a denormalised available_bikes counter in the stations table, updated transactionally when a rental starts and finishes: that is precomputation inside the transaction, not caching, and therefore always coherent. One admissible exception would be a TTL of 2-3 seconds, which absorbs bursts with no perceptible staleness; it is a business decision and has to be put forward as such.

(c) User: yes, carefully, and it is the interesting case. Heavily read and rarely written, but deactivation has security implications: if the council blocks a citizen in arrears and the cache keeps saying they are active, they will carry on renting bikes. Hence: Redis —it must look the same on all three instances—, entryTtl=5m as a safety net and explicit invalidation in AFTER_COMMIT on the three write paths: editing the profile, changing the fare type and activating/deactivating. And a rule worth committing to memory: a sensitive authorisation check never rests on the cache alone; the 05-04 JWT already carries the roles and its short expiry is the real defence.

(d) Amount: no. It fails the first condition: even with 900 requests per minute, each one is for a different rental. There is no key repetition and the hit rate would be almost zero: a cache that only costs memory and invalidation. What can be cached is the fare catalogue the calculation consults, which is case (a). The general distinction: you cache the inputs to a calculation, not its individual result.

Solution 2.

Defect 1 — self-invocation (section 7). findByType calls this.list(), which does not go through the proxy, so 100 % of the by-type calls go to the database. Symptom: the cache exists, cache.puts is low, and the hit rate is ridiculous compared with the traffic. Worse still, the failure looks intermittent: if somebody calls list() from a controller first, that call does hit.

Defect 2 — the invalidation key does not exist. list() takes no arguments, so its key is SimpleKey.EMPTY; @CacheEvict(key = "#fare.type") removes a key (STUDENT) that has never been written. Symptom: updating a fare has no visible effect at all for the whole TTL; the evict runs, does not fail and removes nothing, which is the worst possible combination.

Defect 3 — invalidation inside the transaction (section 12). Even if the key were correct, @CacheEvict can run before the commit: another concurrent request repopulates with the old value. And if the save ends in a rollback, you will have invalidated because of a change that never existed.

Corrected version:

@Service
public class FareService {

    public static final String CACHE_FARES = "fares";

    @Cacheable(cacheNames = CACHE_FARES, key = "'all'")   // explicit, stable key
    public List<FareResponse> list() { ... }

    @Cacheable(cacheNames = CACHE_FARES, key = "#type.name()")   // goes through the proxy
    public FareResponse findByType(FareType type) {
        return fareRepository.findByType(type).map(mapper::toResponse).orElseThrow(...);
    }

    @Transactional
    public void update(Fare fare) {
        fareRepository.save(fare);
        publisher.publishEvent(new FareUpdated(fare.getType()));
    }
}

@Component
class FareCacheInvalidator {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onFareUpdated(FareUpdated event) {
        Cache cache = cacheManager.getCache(FareService.CACHE_FARES);
        if (cache != null) { cache.evict(event.type().name()); cache.evict("all"); }
    }
}

The four changes: findByType queries the repository instead of reusing list(), so its @Cacheable really does act; both keys are explicit; the invalidation happens in AFTER_COMMIT; and both entries are removed, because changing a fare also invalidates the aggregated listing —the detail most often forgotten when a per-item cache and a per-collection cache coexist—.

Solution 3.

What is happening. Caffeine lives inside each JVM. The PUT /api/v1/stations/2 request reached a single instance —whichever the load balancer chose—, which updated PostgreSQL and invalidated its copy. The other two instances know nothing about it and go on serving "North Station" for the 30 minutes of the expireAfterWrite. Since the load balancer spreads the requests around, the citizen sees the new name roughly a third of the time. It is not reproducible locally, where there is only one instance, and that is why a local cache "stops being innocent" once you scale.

Three solutions and their trade-offs. (1) A short TTL: drop expireAfterWrite to 60 s. Zero cost, and the incoherence lasts a minute at most, but the hit rate falls and the problem does not disappear, it is merely shortened. (2) Redis: a single shared copy, global and instantaneous invalidation, and the cache survives the restarts of a rolling deployment; in exchange, one more service to operate, a network dependency on every read and the serialisation from section 10. (3) Caffeine with broadcast invalidation over pub/sub: nanosecond latency and near-immediate coherence, but it is the option with the most moving parts and you have to work out what happens when an instance misses a message.

Choice: Redis. The station catalogue is consulted by every instance, has dozens of entries —a ridiculous volume for Redis— and coherence visible to the citizen is a council requirement. Redis is also coming in anyway for the user cache in exercise 1, so the operational cost is amortised.

spring:
  cache:
    type: redis
  data:
    redis:
      host: ${REDIS_HOST:redis}
      timeout: 500ms
@Bean
RedisCacheManagerBuilderCustomizer perCacheConfiguration() {
    return builder -> builder
            .withCacheConfiguration("stations", base().entryTtl(Duration.ofMinutes(10)))
            .withCacheConfiguration("users",    base().entryTtl(Duration.ofMinutes(5)));
}

@Component
class StationCacheInvalidator {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onUpdate(StationUpdated event) {
        cacheManager.getCache("stations").evict(event.id());   // removes it for all three
    }
}

With the evict in AFTER_COMMIT and a single copy in Redis, the invalidation is global: the instance handling the modification removes the entry for all of them, and the next read from any of the three repopulates it with the new name. The 10-minute TTL is kept as a safety net (section 12) and you have to decide the behaviour when Redis goes down: with timeout: 500ms and a CacheErrorHandler that logs the failure instead of propagating it, the application degrades to querying the database, which is slow but correct. The opposite —a downed Redis bringing down the stations endpoint— would be trading a coherence problem for an availability one.

Conclusion

CicloUrbana now has its second performance lever, and it has it in the right order: you fix the query first and only then cache, because a cache over bad code buys silence and hides the cause. You know how to decide what deserves a cache with the three conditions —heavily read, rarely written, tolerant of being slightly stale— and why in Ribalta that means caching the station catalogue and the fares, but never real-time availability or the rental in progress: you cache the catalogue, never the state.

You have mastered Spring's abstraction —@EnableCaching, CacheManager, Cache and the CacheInterceptor— and why your code never mentions Caffeine or Redis. You know the five annotations with their attributes, the real difference between @Cacheable and @CachePut, and what allEntries and beforeInvocation do. You know that keys are the biggest source of silent errors —the method with no arguments, the entity as a key, the Pageable generating hundreds of entries— and you declare them with SpEL or with your own KeyGenerator. You tell condition from unless by the moment they are evaluated, and you have identified the mistake of caching a null or an Optional.empty() without having decided to. And you have seen the proxy trap for a third time, with its unmistakable signature: a hit rate close to zero.

On the infrastructure side, you know why ConcurrentMapCache is no good in production, you configure Caffeine with maximumSize, expireAfterWrite and recordStats tuning cache by cache, and you set up Redis with a per-cache TTL, JSON serialisation and the three faces of the type problem —JavaTimeModule, ISO-8601 and package-restricted typing—, with its container in the 07-04 docker-compose.yml and its Testcontainers test. You have the local-versus-distributed table and, above all, the reason why a local cache stops being innocent once you scale to three instances. And you have worked through the hard part: invalidating in AFTER_COMMIT and never inside the transaction, a TTL as a safety net alongside explicit invalidation, the stampede mitigations —sync, refreshAfterWrite, jitter and warming—, the comparison with Hibernate's second-level cache and why we prefer the explicit one, and the HTTP caching with ETag and Cache-Control that costs nothing.

One loose end remains, and it has come up in almost every section: measuring. The hit rate that tells a useful cache from a useless one, cache.evictions giving away a badly chosen size, and before that the p95 latency from 09-01, the GC pauses, the connections waiting on the pool. All those numbers already exist inside the process, published by the Actuator we set up in 07-01, and so far we have consulted them by ear. The next lesson, Monitoring with Spring Boot Actuator, turns them into real instrumentation: Micrometer as the metrics facade, the meter types, the metrics that already exist without writing any code, the golden rule about tag cardinality, and the business metrics of the Ribalta network —rentals started by fare, available bikes per station, journey durations— with percentiles that can be aggregated across instances.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved