Here is the English translation of the article body.
You added @Cacheable, but passing different arguments still returns the same result. Even null gets dutifully cached. You have two parameters and have no idea how to write the key. Almost every caching question comes down to one of these three.
The root cause in all of them is “key and condition design.” This article assumes @EnableCaching and the provider are already configured, and focuses on how keys are determined, SpEL in the key attribute, custom KeyGenerators, the difference between condition and unless, and how to combine operations with @Caching. If you want to review the basics first, read the basics of Spring Boot’s caching feature before continuing.
Prerequisites and Test Environment
Everything was verified with Spring Boot 3.x (Spring Framework 6.x) and Caffeine 3.x. This is the only configuration needed.
spring:
cache:
type: caffeine
caffeine:
spec: recordStats
recordStats is there so we can read hit counts in the final verification step. Tuning options such as maximumSize and expireAfterWrite are covered in the Caffeine cache tuning article and are not discussed here. All samples use a UserService.
What Becomes the Key When You Don’t Write One
When you omit the key attribute, SimpleKeyGenerator determines the key. The rule depends on the number of arguments.
- No arguments: SimpleKey.EMPTY
- One argument: the value itself (a Long 1L or a String is used directly as the key; it is only wrapped in a SimpleKey when the argument is null or an array)
- Two or more arguments: a SimpleKey wrapping all the arguments
SimpleKey determines equality using the equals/hashCode of the arguments it contains. This means that if you bundle search criteria into an object and pass it in, that class needs equals/hashCode. If you pass a plain class without them, every call is treated as a different key, and you get the symptom “the cache never works at all.” Declaring it as a record such as record UserQuery(Long id, String locale) gives you those implementations automatically, so you are safe.
The other important point is that neither the method name nor the class name is part of the key. This leads to the next pitfall.
Pitfall - Sharing One Cache Name Across Multiple Methods Causes Key Collisions
@Service
public class UserService {
@Cacheable("users")
public User findById(Long id) { ... }
@Cacheable("users")
public UserSummary findSummaryById(Long id) { ... }
}
Both methods have the key 1L. If you call findById(1L) first, a User is stored in “users”. When you then call findSummaryById(1L), it is a cache hit and that User is returned. Because the return types differ, the caller gets a ClassCastException.
@Test
void 同じキャッシュ名はキーが衝突する() {
userService.findById(1L);
assertThrows(ClassCastException.class,
() -> userService.findSummaryById(1L));
}
If the return types happen to be the same, there is not even an exception. It breaks silently in the form of “a different method returns the same result.” There are three ways around this: use separate cache names, include #root.methodName in the key, or use a custom KeyGenerator. We will look at the latter two below.
Note that among the “cache doesn’t work” symptoms, those caused by calls from within the same class (self-invocation) are a separate issue from key design. If that sounds familiar, check the article on AOP proxies and self-invocation. The case where @Transactional rollback fails for the same reason is also covered in the transaction management article.
Writing SpEL in the key Attribute
// 引数名で参照
@Cacheable(value = "users", key = "#id")
public User findById(Long id) { ... }
// オブジェクトのプロパティ
@Cacheable(value = "users", key = "#user.id")
public User refresh(User user) { ... }
// 複数引数を連結した複合キー
@Cacheable(value = "users", key = "#id + ':' + #locale")
public User findByIdAndLocale(Long id, String locale) { ... }
// メソッド名を混ぜて衝突を避ける
@Cacheable(value = "users", key = "#root.methodName + ':' + #id")
public UserSummary findSummaryById(Long id) { ... }
Referencing parameters by name, as in #id, depends on the -parameters compiler option. It is enabled by default if you use the Spring Boot Gradle plugin, or if you use spring-boot-starter-parent as your parent POM in Maven. In a setup that only imports the BOM without the parent POM, it is not enabled, so you need to add <parameters>true</parameters> to maven-compiler-plugin yourself. Since Spring Framework 6.1, name resolution from debug information has also been removed, so if the names are unavailable, #id evaluates to null and the first call fails like this.
java.lang.IllegalArgumentException: Null key returned for cache operation [...]. If you are using named parameters, ensure that the parameter names are available in the compiled class file.
If you build with plain javac, you can fall back to #p0 or #a0 (the argument at index 0).
From #root you can reference methodName, targetClass, target, args, and caches. #result cannot be used in the key of @Cacheable, because the key is determined before the method runs. #result is available in exactly three places: unless, the key of @CachePut, and @CacheEvict with beforeInvocation = false. Each of these comes up later.
Another scary one is typos. If you write a nonexistent variable such as #idd, SpEL does not throw an exception. It returns null. With a standalone key you will notice via the Null key error above, but with a concatenated key such as #root.methodName + ':' + #idd, the key becomes the fixed string findSummaryById:null, and every call silently shares the same entry with no exception at all. A SpelEvaluationException is only raised for a mistyped property name such as #user.idd. This is why your tests need to inspect the actual cache contents.
Enforcing Shared Rules with a Custom KeyGenerator
Rules such as “include the class name and method name in every method” or “mix in the tenant ID” are safer to centralize in a KeyGenerator than to write in SpEL every time.
@Component("tenantKeyGenerator")
public class TenantKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
// テスト等でテナントが無い場合に備えてデフォルト値を入れる
String tenant = Objects.requireNonNullElse(TenantContext.get(), "default");
// target がプロキシでも元クラス名を取るために ultimateTargetClass を使う
String className = AopProxyUtils.ultimateTargetClass(target).getSimpleName();
return tenant + ":" + className + "." + method.getName()
+ ":" + SimpleKeyGenerator.generateKey(params);
}
}
@Cacheable(value = "users", keyGenerator = "tenantKeyGenerator")
public User findById(Long id) { ... }
key and keyGenerator cannot be specified together. If you write both, startup fails with this error.
java.lang.IllegalStateException: Invalid cache annotation configuration on 'public com.example.User com.example.UserService.findById(java.lang.Long)'. Both 'key' and 'keyGenerator' attributes have been set. These attributes are mutually exclusive: ...
If you want it applied to every method that does not specify a key, implement CachingConfigurer and replace the default KeyGenerator. Injecting the Bean registered with @Component above and returning it directly avoids creating a duplicate instance.
@Configuration
public class CacheConfig implements CachingConfigurer {
private final TenantKeyGenerator tenantKeyGenerator;
public CacheConfig(TenantKeyGenerator tenantKeyGenerator) {
this.tenantKeyGenerator = tenantKeyGenerator;
}
@Override
public KeyGenerator keyGenerator() {
return tenantKeyGenerator;
}
}
Be aware that this affects every cache name, so for readability it is better to commit to either this approach or per-method specification via the keyGenerator attribute, not both.
The Difference Between condition and unless
Both look like “don’t cache when the condition is met,” but they are evaluated at different times.
- condition is evaluated before the method runs. Only the arguments are available
- unless is evaluated after the method runs. The return value is available via #result
“Don’t cache null” requires looking at the return value, so that is a job for unless.
// id が正のときだけ対象にし(実行前)、null は入れない(実行後)
@Cacheable(value = "users", condition = "#id > 0", unless = "#result == null")
public User findById(Long id) { ... }
// null と空リストは入れない(null 上で isEmpty() を呼ぶと例外になるので先に弾く)
@Cacheable(value = "userList", unless = "#result == null || #result.isEmpty()")
public List<User> findAll() { ... }
// Optional は Spring が中身を取り出してから評価する
@Cacheable(value = "users", unless = "#result == null")
public Optional<User> findOptional(Long id) { ... }
A common mistake is writing condition = "#result != null". Because it is evaluated before execution, #result is null, the condition is always false, and nothing is ever cached.
For Optional return values, since Spring Framework 4.3 (and the same in 6.x), the contents are unwrapped before being stored in the cache. If the value is Optional.empty, #result in unless is null, so an ordinary null check filters it out. On a hit, the value is re-wrapped in an Optional before being returned, so the caller does not need to think about it.
Let’s also sort out the evaluation order. If condition is false, the cache is neither read nor written, and unless is not evaluated. On a cache hit, the method does not run, so unless is not evaluated, and the stored value is returned as-is.
Keep key Consistent in @CachePut and @CacheEvict Too
key and condition work the same way on the update and eviction side. The important thing is to write the same expression as the read side. If the expressions diverge, you end up with a separate entry, and stale values keep coming back even though you supposedly updated them.
@CachePut(value = "users", key = "#user.id")
public User update(User user) { ... }
// @CachePut の key は実行後に評価されるので、採番された ID を #result で使える
@CachePut(value = "users", key = "#result.id")
public User create(UserForm form) { ... }
// メソッドが例外を投げても消したいなら beforeInvocation = true
@CacheEvict(value = "users", key = "#id", beforeInvocation = true)
public void delete(Long id) { ... }
One thing to watch out for is when the read side includes #root.methodName in the key. On the @CachePut side, methodName becomes update, so the keys do not match. For caches that also need updates and deletes, it is easier to keep the expressions aligned by using separate cache names or a fixed prefix such as 'summary:' + #id. Remember that #root.methodName is best suited to read-only caches.
List-style caches are hard to target by key, so evicting them in bulk with allEntries = true is the practical approach. Also, #result is only available in @CacheEvict when beforeInvocation = false. If you evict before execution, there is no return value yet, which makes sense.
The key design principles are the same when Redis is your provider, but key serialization comes into play, so also check the Redis and Spring Boot integration article.
Combining Multiple Operations with @Caching
You cannot place two of the same annotation on one method. For situations such as updating the ID-based cache while discarding the list cache, use @Caching.
@Caching(
put = @CachePut(value = "users", key = "#user.id"),
evict = @CacheEvict(value = "userList", allEntries = true)
)
public User update(User user) { ... }
@Caching(evict = {
@CacheEvict(value = "users", key = "#id"),
@CacheEvict(value = "userSummary", key = "#id"),
@CacheEvict(value = "userList", allEntries = true)
})
public void delete(Long id) { ... }
Each inner operation can have its own condition and unless independently. If you find yourself writing the same combination repeatedly, create a composed annotation (such as @UserCacheEvict) with @Caching as a meta-annotation, and the method side becomes much cleaner.
Verifying Keys Behave as Intended with Caffeine Stats
The most reliable way to confirm that your designed keys actually work is to check hit counts. Retrieve the CaffeineCache from the CacheManager and read stats().
@SpringBootTest
class UserCacheKeyTest {
@Autowired UserService userService;
@Autowired CacheManager cacheManager;
private CaffeineCache users;
private CacheStats before;
@BeforeEach
void setUp() {
users = (CaffeineCache) cacheManager.getCache("users");
users.clear(); // テスト間でエントリを共有しない
before = users.getNativeCache().stats(); // 統計は累計なので差分を取る
}
private CacheStats delta() {
return users.getNativeCache().stats().minus(before);
}
@Test
void 同じ引数の2回目はヒットする() {
userService.findById(1L);
userService.findById(1L);
assertThat(delta().missCount()).isEqualTo(1);
assertThat(delta().hitCount()).isEqualTo(1);
}
// findSummaryById は key = "#root.methodName + ':' + #id" を付けた版が前提
// (冒頭の衝突サンプルのままだと ClassCastException になる)
@Test
void 別メソッドとはキーが衝突しない() {
userService.findById(1L);
userService.findSummaryById(1L);
assertThat(delta().hitCount()).isZero();
// users.get() 自体も stats に乗るので、hitCount の assert より後に置く
assertThat(users.get("findSummaryById:1")).isNotNull();
}
@Test
void nullはキャッシュされない() {
userService.findById(999L); // 存在しない ID
assertThat(users.get(999L)).isNull();
}
}
Caffeine’s statistics are not reset by clear(), so we compare against the values captured in @BeforeEach. The second test looks directly at the key string, so it also catches the concatenated-key typo described earlier. For monitoring hit rates in production, refer to the Micrometer integration in the Caffeine tuning article.
Summary
Here are the key design points to keep in mind.
- Don’t share a cache name across methods with different return types. Use #root.methodName for read-only caches, and separate cache names when updates are involved
- If you use an argument object as the key, make it a record to guarantee equals/hashCode
- Filter out null and empty results with #result in unless. Never write #result in condition
- Keep key expressions aligned across read, update, and delete, and combine multiple operations with @Caching
- Verify hit counts and cache contents with recordStats and tests
Because @Cacheable is so convenient, it is easy to miss that the key is being decided “somehow” behind the scenes. Take a look inside the cache in a test once, and you will spend far less time puzzling over mysterious behavior in production.