condition and unless both gate caching, but they fire at opposite ends of the call and see different data — condition is evaluated before the method on the arguments, unless is evaluated after on the result. Confusing the two is the whole trap.
condition — gate on the input, before the call
condition is a SpEL predicate over the method arguments. If it evaluates false, the proxy treats the call as un-cacheable: it doesn't look the value up and doesn't store it — the method just runs normally. Because it runs first, condition can decide to skip the cache without invoking the method (on @Cacheable).
@Cacheable(value = "books", condition = "#id > 0")
public Book find(long id) { ... } // negative ids bypass the cache entirely
unless — veto on the output, after the call
unless is evaluated after the method returns, and it can reference the return value via #result. If it evaluates true, the result is not stored (note the inverted polarity: unless says "don't cache when…"). The method always runs first, so unless cannot prevent invocation — only storage.
@Cacheable(value = "books", unless = "#result == null")
public Book find(long id) { ... } // never cache a null/miss
The canonical idiom is "cache only non-empty results":
@Cacheable(value = "search", key = "#q",
unless = "#result == null || #result.isEmpty()")
public List<Book> search(String q) { ... }
Putting them together
@Cacheable(value = "books",
condition = "#refresh == false", // skip lookup when caller forces refresh
unless = "#result == null") // don't store empty answers
public Book find(long id, boolean refresh) { ... }
On a @Cacheable hit, neither runs the method, but condition still controls whether the cache is even consulted. On @CacheEvict/@CachePut, only condition applies (unless is meaningful where a result is stored, i.e. @Cacheable and @CachePut).