backend

9 min read

Spring Boot Caching With @Cacheable (Step-by-Step)

Learn how to add caching to any Spring Boot app in minutes using @Cacheable. No Redis, no extra infrastructure - just one annotation and faster responses.

Spring Boot Caching With @Cacheable (Step-by-Step) thumbnail

Published By: Nelson Djalo | Date: April 8, 2026

Your Spring Boot endpoint is hammering the database for the same data over and over. Every request runs the same query, returns the same rows, and burns the same CPU cycles. You don't need Redis to fix this. You need one annotation, one starter, and about ten minutes.

This post walks through Spring's built-in caching abstraction with @Cacheable, the in-memory store that ships out of the box, and the gotchas that catch most developers the first time.

Table of Contents

What Is the Spring Cache Abstraction

Spring Cache is not a cache. It's an abstraction layer that sits in front of one. You annotate a method, Spring wraps it in a proxy, and on each call the proxy checks a cache before invoking the actual method. If the value is there, the method never runs. If it isn't, the method runs and the result gets stored.

The point of this design is that you write the same annotations regardless of what cache lives underneath. You can start with a simple in-memory ConcurrentHashMap, then swap in Caffeine, Ehcache, Hazelcast, or Redis later without changing a single line in your service code. The annotations stay the same. Only the configuration changes.

Adding the Spring Boot Cache Starter

You only need one dependency to get going. Add this to your pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

Or in Gradle:

implementation 'org.springframework.boot:spring-boot-starter-cache'

That's it. No external services, no Docker container, no config files. Spring Boot auto-configures a ConcurrentMapCacheManager that stores entries in a plain ConcurrentHashMap inside your JVM. It's fast, it's simple, and it's perfect for getting started or for small single-instance apps.

Enabling Caching With @EnableCaching

Caching is opt-in. Add @EnableCaching to any @Configuration class - usually your main application class works fine:

@SpringBootApplication
@EnableCaching
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Without this annotation, every @Cacheable you write will be silently ignored. This is the most common mistake people make on day one. If your cache doesn't seem to be working, check this first.

Basic @Cacheable Usage

Here's a typical service method that hits the database every time it's called:

@Service
public class ProductService {

    private final ProductRepository repository;

    public Product findById(Long id) {
        return repository.findById(id)
            .orElseThrow(() -> new ProductNotFoundException(id));
    }
}

Now add @Cacheable:

@Cacheable("products")
public Product findById(Long id) {
    return repository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
}

The first call runs the method and stores the result under the key id in a cache named products. Every subsequent call with the same id returns the cached value without ever touching the repository. That's the entire feature in three words.

Custom Cache Names and Keys

By default, Spring uses the method parameters as the cache key. With one parameter, that's just the parameter itself. With multiple parameters, Spring builds a composite key using SimpleKeyGenerator. Most of the time you want more control:

@Cacheable(value = "products", key = "#id")
public Product findById(Long id) {
    return repository.findById(id).orElseThrow();
}

The value (or cacheNames) attribute names the cache. The key attribute uses Spring Expression Language (SpEL) to build a custom key. You can reference any parameter by name, access nested fields, or call methods:

@Cacheable(value = "users", key = "#user.email")
public UserProfile loadProfile(User user) {
    return profileService.fetch(user.getEmail());
}

@Cacheable(value = "orders", key = "#customerId + '-' + #status")
public List<Order> findOrders(Long customerId, OrderStatus status) {
    return orderRepository.findByCustomerAndStatus(customerId, status);
}

SpEL gives you full flexibility. You can reference #root.method.name, #root.target, or any custom bean using @beanName.method(). For most apps, simple parameter references are enough.

@CachePut for Updates

@Cacheable skips the method when there's a hit. @CachePut always runs the method and updates the cache with the result. Use it for write operations where you want the cache to reflect the new state:

@CachePut(value = "products", key = "#product.id")
public Product update(Product product) {
    return repository.save(product);
}

Now whenever you update a product, the cache entry for that ID gets refreshed with the new data instead of going stale. The next read sees the fresh value without a database hit.

@CacheEvict for Invalidation

When data changes and you want to drop it from the cache rather than update it, use @CacheEvict:

@CacheEvict(value = "products", key = "#id")
public void delete(Long id) {
    repository.deleteById(id);
}

You can also wipe the entire cache by setting allEntries = true. This is useful for bulk operations where individual key tracking gets messy:

@CacheEvict(value = "products", allEntries = true)
public void reloadCatalog() {
    catalogService.reimport();
}

There's also beforeInvocation = true if you want the eviction to happen before the method runs (useful when the method might throw an exception and you still want the entry gone).

Conditional Caching With condition and unless

Sometimes you only want to cache certain results. Maybe you don't want to cache null values, or you only care about expensive lookups. Spring gives you two SpEL hooks for this:

  • condition - evaluated before the method runs. If false, caching is skipped entirely.
  • unless - evaluated after the method runs. Has access to the return value via #result.
@Cacheable(value = "products", key = "#id", condition = "#id > 1000")
public Product findById(Long id) {
    return repository.findById(id).orElseThrow();
}

@Cacheable(value = "products", key = "#name", unless = "#result == null")
public Product findByName(String name) {
    return repository.findByName(name);
}

The first example only caches products with IDs above 1000. The second caches everything except null results - a common pattern to avoid storing misses.

The Default In-Memory Cache Manager

When you add the cache starter without configuring anything, Spring Boot gives you a ConcurrentMapCacheManager. It's a wrapper around a ConcurrentHashMap per cache name. Entries live in your JVM heap. There's no eviction, no TTL, no size limit. Once something is in there, it stays until you evict it manually or the application restarts.

This is great for prototypes and small apps with bounded data sets. It falls apart when:

  • You have multiple application instances and need a shared cache
  • You need automatic expiration based on time or size
  • Your dataset would blow up your heap

For controlled in-memory caching with eviction policies, drop in Caffeine. It's a one-line change in application.properties:

spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=10m

When to Upgrade to Redis

The default cache works perfectly until you scale horizontally. The moment you run two instances of your app behind a load balancer, each instance has its own cache. User A hits instance 1 and warms its cache. User B hits instance 2 and gets a cold miss. Worse, when you update data on one instance, the other instance still serves stale entries.

That's when you reach for Redis. It's a shared cache that all instances talk to, so there's one source of truth. The annotations don't change - only the cache manager does. If you're at that stage, read Spring Boot + Redis Caching for the full setup.

Common Pitfalls

Self-invocation doesn't work. Spring caching uses proxies. When you call a @Cacheable method from another method in the same class, the call bypasses the proxy and the cache is never consulted:

@Service
public class ProductService {

    @Cacheable("products")
    public Product findById(Long id) { ... }

    public List<Product> findAll(List<Long> ids) {
        // This call SKIPS the cache because it's an internal call
        return ids.stream().map(this::findById).toList();
    }
}

The fix is to call the cached method from a different bean, or to inject the service into itself, or to use AopContext.currentProxy(). Most teams just split the code into two services.

Caching void methods is pointless. @Cacheable only makes sense for methods that return a value. There's nothing to cache otherwise. Spring won't stop you from annotating a void method, but it won't do anything useful either.

Cached objects must be serializable for some backends. The default in-memory cache stores object references, so this isn't a concern. The moment you switch to Redis, Hazelcast, or any distributed backend, your cached objects need to be serializable. Add implements Serializable and a serialVersionUID early so it's not a surprise later.

Watch out for caching mutable objects. The cache returns the same reference every time. If a caller mutates the returned object, the next caller sees the mutation. Either return defensive copies or use immutable types like records.

Want a deeper dive into Spring Boot internals like this? The Spring Boot Master Class walks through caching, transactions, security, and everything else you need to ship production apps. Or if you're earlier in your journey, check the Spring Boot Roadmap for a structured path.

FAQ

Do I need Redis to use Spring caching? No. Spring Boot ships with an in-memory ConcurrentMapCacheManager out of the box. You only need Redis or another external store when you scale to multiple instances or need shared state.

Why isn't @Cacheable working? The most common reasons: you forgot @EnableCaching, you're calling the cached method from inside the same class (self-invocation), or the method is private. Spring proxies only intercept public methods called from outside the bean.

How do I set a TTL on cached entries? The default ConcurrentMapCacheManager doesn't support TTLs. Switch to Caffeine with spring.cache.type=caffeine and spring.cache.caffeine.spec=expireAfterWrite=10m to get time-based eviction.

Can I use @Cacheable with method parameters that are objects? Yes. Use SpEL in the key attribute to reference fields on the object - for example key = "#user.id". If you skip the key and pass an object, Spring uses the object's hashCode and equals, so make sure those are implemented correctly.

What's the difference between @Cacheable, @CachePut, and @CacheEvict? @Cacheable skips the method if the value is cached. @CachePut always runs the method and updates the cache. @CacheEvict removes entries from the cache. Use @Cacheable for reads, @CachePut for writes that should refresh the cache, and @CacheEvict for deletes or invalidation.

Wrapping Up

Spring's caching abstraction turns a multi-step performance optimization into a one-line change. Start with the in-memory default, profile your app, and only upgrade to a distributed cache when you actually need one.

Ready to learn Spring Boot the right way? Join the Spring Boot Master Class and build real apps from scratch, or check out Building APIs with Spring Boot for hands-on REST API work.

Your Career Transformation Starts Now

Join thousands of developers mastering in-demand skills with Amigoscode. Try it free today.