Caching with @Cacheable

Reviewed & published by Brayan K

Spring's caching abstraction lets a single annotation — @Cacheable — remember an expensive method's result, while @CacheEvict keeps it fresh and providers like Caffeine or Redis do the storing.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

Before You Start

You should understand Spring beans and services, and be comfortable with maps and lambdas for the memoization demos.

What You'll Learn

The Big Idea — A Sticky Note on Your Monitor

💡 Analogy: Imagine someone keeps asking you for the office Wi-Fi password. The first time you walk to IT and fetch it (the expensive call); then you scribble it on a sticky note on your monitor. Every later request is answered instantly from the note — that's @Cacheable. But when IT changes the password, the note is now wrong, so you must tear it down and write the new one: that's @CacheEvict. The note can live on your own desk (Caffeine, fast but only yours) or on a shared whiteboard everyone can see (Redis, slower to reach but shared by the whole team).

Fetch the slow answer once, reuse it cheaply — and remember to update the note when the truth changes.

1️⃣ The Core — Remember Results by Key

@Cacheable is memoization: store a method's result keyed by its arguments so the expensive work happens once per key. The plain-Java demo uses computeIfAbsent to make that mechanism explicit.

import java.util.*;
import java.util.function.*;

public class Main {
    // @Cacheable in one idea: remember a method's result keyed by its argument,
    // so an EXPENSIVE call only happens ONCE per key. We build that wrapper by
    // hand (memoization) to make the mechanism concrete.
    static int computeCalls = 0;

    static int slowSquare(int n) {                 // pretend this is expensive
        computeCalls++;
        return n * n;
    }

    public static void main(String[] args) {
        Map<Integer, Integer> cache = new HashMap<>();
        Function<Integer, Integer> cached = key ->
            cache.computeIfAbsent(key, Main::slowSquare);   // compute once per key

        System.out.println("square(4) = " + cached.apply(4));   // MISS -> computes
        System.out.println("square(4) = " + cached.apply(4));   // HIT  -> cached
        System.out.println("square(5) = " + cached.apply(5));   // MISS -> computes

        System.out.println("expensive computations: " + computeCalls);
    }
}

2️⃣ Staying Correct — Eviction

A cache speeds reads but can go stale. When the source data changes you must remove the old entry — that's @CacheEvict. Forget it and readers keep seeing the outdated value.

import java.util.*;

public class Main {
    // @CacheEvict keeps the cache CORRECT: when the underlying data changes, you
    // must remove the stale entry, or readers keep seeing the old value. We model
    // a put-that-evicts to show why eviction matters.
    static Map<Integer, String> store = new HashMap<>(Map.of(1, "Ada"));
    static Map<Integer, String> cache = new HashMap<>();

    static String getName(int id) {                 // @Cacheable-style read
        return cache.computeIfAbsent(id, store::get);
    }

    static void rename(int id, String name) {       // @CacheEvict-style write
        store.put(id, name);
        cache.remove(id);                           // EVICT the stale entry
    }

    public static void main(String[] args) {
        System.out.println("first read : " + getName(1));   // caches "Ada"
        rename(1, "Ada Lovelace");                          // update + evict
        System.out.println("after update: " + getName(1));  // re-reads fresh value
        System.out.println("without eviction we'd still see: Ada (stale)");
    }
}

3️⃣ The Spring Way — @EnableCaching + Annotations

Turn caching on once with @EnableCaching, then annotate methods: @Cacheable to read-through, @CacheEvict to invalidate, @CachePut to refresh. Spring applies the logic via AOP proxies.

@Cacheable("books")            // read-through: skip body on a hit
@CacheEvict(value="books", key="#book.isbn")   // invalidate on write
@CachePut(value="books", key="#book.isbn")     // run + refresh entry

4️⃣ Choosing a Provider — Caffeine vs Redis

Spring caches against whatever provider is on the classpath. Caffeine is a fast in-process cache for a single instance; Redis is a distributed cache shared across instances and surviving restarts.

# Choosing a CACHE PROVIDER (Spring caches against whichever is on the classpath).

# --- Option A: Caffeine (fast, in-process, single-instance) ---
#   build dependency: com.github.ben-manes.caffeine:caffeine
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=5m

# --- Option B: Redis (distributed, shared across instances) ---
#   build dependency: spring-boot-starter-data-redis
# spring.cache.type=redis
# spring.data.redis.host=localhost
# spring.data.redis.port=6379
# spring.cache.redis.time-to-live=300000

# Rule of thumb:
#   Caffeine -> one app instance, ultra-low latency, no extra infrastructure.
#   Redis    -> many instances must share the SAME cache; survives restarts.

🧠 Quick Recall

Predict before you peek.

Answer: no — it's a cache hit, so the stored value is returned and the body is skipped.

Answer: @CacheEvict (or @CachePut) on the write removes/refreshes the affected entry.

Answer: Redis — a distributed cache. Caffeine is local to a single JVM.

🎯 YOUR TURN — Memoized Fibonacci

Complete fib(n) using the cache map so each n is computed only once.

import java.util.*;
import java.util.function.*;

public class Main {
    // YOUR TURN: implement a memoized (cached) Fibonacci.
    // Use the provided cache map so each n is computed only once. Count calls
    // to the 'compute' path to prove caching works.
    //
    // Expected output:
    //   fib(10) = 55

    static Map<Integer, Long> cache = new HashMap<>();

    static long fib(int n) {
        // if n < 2 return n
        // otherwise return cache.computeIfAbsent(n, k -> fib(k-1) + fib(k-2));
        return 0;
    }

    public static void main(String[] args) {
        System.out.println("fib(10) = " + fib(10));
    }
}

🧩 MINI-CHALLENGE — Read-Through Cache with Eviction

Implement get (cache-through) and set (update + evict) and prove the read after a set is fresh.

Common Errors

Pro Tips

📋 Quick Reference

ConceptSyntax / ideaNote
enable@EnableCachingOn a config class
read-through@Cacheable("books")Skip body on hit
keykey = "#book.isbn"SpEL, default = args
invalidate@CacheEvictRemove stale entry
refresh@CachePutRun + update cache
clear all@CacheEvict(allEntries=true)Whole cache
in-processCaffeineFast, single node
distributedRedisShared, survives restart
mechanismAOP proxyIntercepts the call
riskstale dataEvict + TTL

📚 Resources / Keep Learning

🎉 Lesson Complete!

You now understand how @Cacheable memoizes results by key, how keys are derived, how @CacheEvict and @CachePut keep data fresh, how @EnableCaching turns it all on, and when to choose Caffeine versus Redis.

Practice quiz

What does @Cacheable do?

  • Deletes a cache
  • Stores a method's result so subsequent calls with the same key return the cached value
  • Encrypts the result
  • Runs the method twice

Answer: Stores a method's result so subsequent calls with the same key return the cached value. @Cacheable caches the return value keyed by the method arguments; later matching calls skip the method body and return the cached value.

Which annotation enables Spring's caching abstraction?

  • @EnableCaching
  • @StartCache
  • @CacheOn
  • @Caching

Answer: @EnableCaching. You add @EnableCaching to a configuration class to turn on Spring's annotation-driven caching.

What forms the default cache key for a @Cacheable method?

  • The method name
  • The current time
  • The method's parameters
  • A random UUID

Answer: The method's parameters. By default the key is derived from the method arguments; you can customise it with a SpEL key expression.

What does @CacheEvict do?

  • Adds an entry
  • Reads an entry
  • Locks the cache
  • Removes one or all entries from a cache (e.g. after an update)

Answer: Removes one or all entries from a cache (e.g. after an update). @CacheEvict removes entries so stale data is purged — for example when the underlying record changes.

When a @Cacheable method is called a SECOND time with the same arguments...

  • The method body runs again
  • The cached value is returned and the body is skipped
  • An exception is thrown
  • The cache is cleared

Answer: The cached value is returned and the body is skipped. A cache hit returns the stored value without executing the method body — that is the whole point.

Which of these is a common cache provider used with Spring?

  • Caffeine
  • Tomcat
  • Jackson
  • Log4j

Answer: Caffeine. Caffeine is a popular high-performance in-memory cache; Redis is common for distributed caching.

Why might you choose Redis over an in-memory cache like Caffeine?

  • It is simpler to set up
  • It needs no network
  • It is a distributed cache shared across multiple app instances
  • It is always faster

Answer: It is a distributed cache shared across multiple app instances. Redis is an external, shared cache, so multiple application instances see the same cached data — useful when scaling out.

What does @CachePut do?

  • Always skips the method
  • Deletes the cache
  • Disables caching
  • Always executes the method and updates the cache with the result

Answer: Always executes the method and updates the cache with the result. @CachePut runs the method every time and stores its result, keeping the cache in sync without short-circuiting.

A risk of caching is...

  • Slower reads
  • Serving stale data if entries aren't evicted/expired correctly
  • Higher network usage always
  • It breaks compilation

Answer: Serving stale data if entries aren't evicted/expired correctly. Caches can return outdated values; correct eviction, TTLs, and invalidation keep cached data fresh.

Spring's caching is implemented via...

  • bytecode rewriting at runtime by the JVM
  • proxies/AOP that intercept the method call
  • a separate thread per cache
  • database triggers

Answer: proxies/AOP that intercept the method call. Spring uses AOP proxies to intercept calls to cacheable methods and apply the cache logic around them.

Continue this course

Frequently asked questions

What does @Cacheable actually do?

@Cacheable wraps a method so that its return value is stored in a named cache, keyed by the method's arguments. The first time the method is called with a particular set of arguments, the body runs and the result is cached; every subsequent call with the same arguments returns the cached value and skips the method body entirely. This is ideal for expensive operations — a slow database lookup, a remote API call, a heavy computation — that are repeated with identical inputs and produce stable results. You enable the whole mechanism with @EnableCaching on a configuration class.

How does Spring know which cached entry to return?

By default, Spring builds the cache key from the method's parameters, so findByIsbn("123") and findByIsbn("456") are cached separately. When a method has multiple parameters or you want a custom key, you supply a SpEL expression via the key attribute — for example key = "#book.isbn" to key on a field of an argument. Getting the key right is essential: too coarse a key returns wrong results across different inputs, while a unique-enough key ensures each distinct input has its own cached value.

What is the difference between @CacheEvict and @CachePut?

@CacheEvict removes entries from a cache — you put it on methods that change the underlying data (updates, deletes) so stale cached values are purged and the next read fetches fresh data; you can evict a single key or, with allEntries = true, clear the whole cache. @CachePut, by contrast, always executes the method and then stores its return value in the cache, refreshing the entry without ever short-circuiting the call. Use @Cacheable to avoid work, @CachePut to update the cache while still doing the work, and @CacheEvict to invalidate.

Which cache provider should I use — Caffeine or Redis?

Spring's caching is an abstraction; it delegates storage to a provider on the classpath. Caffeine is a fast, in-process, in-memory cache — perfect for a single application instance that wants the lowest possible latency with no extra infrastructure, but its data is local to that one JVM. Redis is an external, distributed cache shared across many instances, so every node sees the same cached data and entries survive an app restart; the trade-off is the added network hop and operational cost of running Redis. Choose Caffeine for single-node speed, Redis for a shared cache when you scale out.

What are the dangers of caching, and how do I avoid them?

The classic danger is stale data: if the underlying value changes but the cache isn't invalidated, readers keep seeing the old value. Guard against it with correct eviction (@CacheEvict on writes), time-to-live expiry, and careful key design. Other pitfalls include unbounded caches that grow until they exhaust memory (set a maximum size), caching values that are cheap to compute (no benefit, just complexity), and caching across instances inconsistently (use a distributed cache like Redis when you scale out). As an old saying goes, cache invalidation is one of the hard problems — so test it.

Related lessons