Describe the Cache and CacheManager SPI contracts. How does the annotation layer sit on top of them, and why does this abstraction matter?
answer
- CacheManager = name → Cache factory
- Cache = get/put/evict/clear + get(key,Callable)
- ValueWrapper distinguishes miss vs cached-null
- interceptor calls the SPI; annotations are sugar
- swap provider = swap CacheManager bean, no code change
basics
~20 sCacheManager is a factory that returns named Cache instances (getCache(name), getCacheNames()). Cache is the store interface: get, put, evict, clear, and get(key, Callable) for atomic loading. The @Cacheable/@CacheEvict annotations are just declarative sugar over these two interfaces, so any provider that implements them plugs in unchanged.
solid answer
~50 sSpring's caching abstraction is two SPI interfaces plus an annotation layer. `org.springframework.cache.CacheManager` is a lookup/factory: `Cache getCache(String name)` returns (often lazily creating) a named cache, and `Collection<String> getCacheNames()` lists them. `org.springframework.cache.Cache` is the store contract: `getName()`, `getNativeCache()`, `get(key)` returning a `ValueWrapper` (a wrapper so a cached null is distinguishable from a miss), typed `get(key, Class)`, `get(key, Callable)` for atomic get-or-load (backing `sync=true`), `put`, `putIfAbsent`, `evict`, `evictIfPresent`, `clear`, and `invalidate`. The annotations don't talk to any provider directly — the `CacheInterceptor` resolves a `CacheManager`, obtains the `Cache`, computes the key, and calls these methods. That inversion is the point: swapping in-memory for Caffeine/Redis/JCache means providing a different `CacheManager` bean; annotated business code never changes. The `ValueWrapper` indirection is the subtle bit — `get` returning `null` means 'no entry', while a wrapper holding `null` means 'null was cached'.
code
java · 19 lines// The SPI the annotation layer drives (simplified signatures):
public interface CacheManager {
Cache getCache(String name);
Collection<String> getCacheNames();
}
public interface Cache {
String getName();
Object getNativeCache();
ValueWrapper get(Object key); // null=miss; wrapper(null)=cached null
<T> T get(Object key, Callable<T> loader); // atomic load, backs sync=true
void put(Object key, Object value);
void evict(Object key);
void clear();
}
// Business code never touches these — the interceptor does:
@Cacheable("books")
public Book find(String isbn) { return db.load(isbn); } // portable across providersgo deeper
Knows CacheManager gives you named Cache objects with get/put.
Can name the core Cache/CacheManager methods and that annotations sit on top of them.
Explains ValueWrapper null-handling, get(key,Callable) backing sync, and provider portability.
Frames the SPI as dependency inversion, reasons about implementing custom Cache/CacheManager (near-cache), and the boundary between interceptor-level and provider-level concerns.
## Two SPIs, one annotation layer Spring separates **what** you declare (annotations) from **how** it's stored (the SPI). There are exactly two interfaces you'd implement to build a provider adapter: ### `CacheManager` — the registry/factory ```java public interface CacheManager { Cache getCache(String name); // returns (often lazily creates) the named cache; may be null Collection<String> getCacheNames(); // known cache names } ``` It maps a **name** (the string in `@Cacheable("books")`) to a `Cache`. Managers differ in whether they pre-declare a fixed set of names or create caches on demand. ### `Cache` — the store contract ```java public interface Cache { String getName(); Object getNativeCache(); // the underlying provider object (e.g. a Caffeine Cache) ValueWrapper get(Object key); // null = miss; wrapper = hit (even wrapping null) <T> T get(Object key, Class<T> type); <T> T get(Object key, Callable<T> loader); // atomic get-or-compute — backs sync=true void put(Object key, Object value); ValueWrapper putIfAbsent(Object key, Object value); void evict(Object key); boolean evictIfPresent(Object key); // returns whether present void clear(); boolean invalidate(); } ``` ### The `ValueWrapper` indirection (the subtle bit) `get(key)` returns a **`Cache.ValueWrapper`**, not the value directly. Why? To distinguish **"no entry"** (method returns `null`) from **"a null value is cached"** (returns a non-null wrapper whose `get()` yields `null`). Providers that can't store nulls typically wrap them with `NullValue`. This is how a cached `null` is a legitimate hit. ## How the annotation layer sits on top The annotations are **pure declaration**. At runtime the AOP proxy invokes `CacheInterceptor` (extending `CacheAspectSupport`), which: 1. Reads the annotation metadata (cache names, key/condition/unless expressions). 2. Resolves the target caches — via a `CacheResolver` that consults the `CacheManager` (resolver/manager selection is a neighboring topic). 3. Computes the key (SpEL `key` or a `KeyGenerator`). 4. Calls `Cache.get`/`put`/`evict`/`get(key,Callable)` accordingly. Business code never references `Cache` or `CacheManager`. That's the **dependency-inversion win**: the same `@Cacheable findByIsbn` runs on a `ConcurrentMapCacheManager` in tests and a Redis-backed manager in production with **no code change** — only a different bean. ## Why the abstraction matters (design view) - **Provider portability** — swap in-memory ↔ Caffeine ↔ JCache ↔ Redis by wiring a different `CacheManager`; annotated code is untouched. - **Testability** — use a trivial in-memory manager in unit tests. - **Uniform semantics** — `sync`, null-handling via `ValueWrapper`, key generation are consistent across providers because they're implemented in the interceptor, not per provider. - **Extensibility** — you can implement a custom `Cache`/`CacheManager` (e.g. a two-tier near-cache) and everything downstream keeps working. ## Gotchas / edge cases - **`getCache` can return `null`** for an unknown name (depending on the manager); the interceptor handles this, but a custom resolver must not NPE. - **`ValueWrapper` vs typed get:** `get(key, Class)` returns the value directly and throws if the stored type mismatches; `get(key)` (wrapper) is the null-safe way to detect presence. - **`getNativeCache()`** is an escape hatch to provider-specific features — using it couples you to the provider, defeating the abstraction. - **Thread-safety and TTL/eviction are the provider's job** — the SPI says nothing about expiry; that's configured on the concrete manager (out of scope here). - **`put` semantics are provider-defined** around size/eviction; the abstraction doesn't guarantee an entry survives. ## When you'd implement the SPI yourself Rarely — only to adapt a store Spring doesn't ship (or to build a composite/near-cache). The common path is configuring an existing provider's `CacheManager`; the SPI matters mostly for *understanding* why annotations are provider-agnostic.
- Why does Cache.get(key) return a ValueWrapper instead of the value directly?To distinguish a cache miss (method returns null) from a legitimately cached null value (returns a non-null wrapper whose getValue() is null). Providers that can't store nulls use a NullValue placeholder. Without the wrapper, a cached null would be indistinguishable from 'not present'.
- How can the same annotated code run on an in-memory cache in tests and Redis in production without changes?The annotations only reference cache names; the interceptor resolves an actual Cache via the CacheManager bean. Swapping the CacheManager (ConcurrentMapCacheManager vs a Redis-backed one) changes the storage while the annotated methods stay identical — classic dependency inversion.
saying these in an interview costs you the question
- Thinking @Cacheable talks to Redis/Caffeine directly rather than through the Cache SPI
- Believing get(key) returns the value directly (ignoring ValueWrapper and cached-null semantics)
- Assuming the Cache SPI defines TTL/eviction (those are provider config)
- Claiming you must change business code to switch cache providers