cswork / libraryOpen practice workspace

Articles

Spring Cache Interview Questions: Proxy Boundaries, Keys, and Stale Results

Practise Spring Cache interviews with proxy self-invocation, tenant-safe keys, condition and unless, transaction-aware eviction, and stale-read refill races.

A service has @Cacheable, but its repository runs twice for the same product. Another service has a high hit rate, yet returns one tenant's price to another tenant. A third clears the cache after an update and still serves the old value. These are different failures. Spring Cache interview questions become easier when you trace the call path, identify the complete key, and draw the update timeline separately.

Official facts below use Spring's documentation checked on October 1, 2026, which currently renders Spring Framework 7.0.9. Match the references to the interview's installed version and provider. Examples and repair recommendations are original exercises and engineering inference, not candidate reports or claims about an employer's question frequency. The main examples use synchronous methods and ordinary proxy-based caching.

What does Spring's cache abstraction actually provide?

Spring supplies a method-level abstraction over a configured cache. It does not turn a Java annotation into a complete distributed consistency policy. Official documentation separates the abstraction from storage and explains that concurrency and multi-process behavior depend on the backing implementation. Understanding the cache abstraction

In this exercise, focus on one product-price lookup rather than a generic Redis architecture.

Assume caching is enabled, the application uses a configured CacheManager, and prices starts empty. The repository returns immutable values and records its load count. No entry expires during the experiment. State these assumptions before predicting hits; otherwise a provider's expiry or an unconfigured interceptor can change the result.

Recommended opening answer: “I would first establish whether the invocation crosses the caching proxy, then inspect cache name and key, and finally check provider and invalidation behavior.” This gives you a diagnostic order without treating every repeated database query as a key bug.

Why does self-invocation bypass Cacheable?

Official fact: default proxy mode intercepts external calls through the proxy. A target object's call to another method on itself does not pass through that boundary. Changing from interface proxies to class proxies does not make that self-call external. Spring caching annotations, AOP proxying mechanisms

Consider this deliberately problematic service fragment:

@Service
class PriceService {
    private final PriceRepository repository;

    PriceService(PriceRepository repository) {
        this.repository = repository;
    }

    @Cacheable(cacheNames = "prices", key = "#p0")
    public Price find(PriceKey key) {
        return repository.load(key);
    }

    public List<Price> twice(PriceKey key) {
        return List.of(find(key), find(key));
    }
}

The types are application-defined; this is a teaching fragment, not a complete runnable project. An external bean calling the injected proxy's find(key) twice should trigger one repository load under our assumptions. Calling the proxy's unannotated twice(key) produces two internal calls to the target's find, so the cache annotation on find does not intercept them.

An interview follow-up may offer new PriceService(repository) as a quick test. That creates a plain object, not the container-managed proxy. It can test repository logic while missing the framework behavior you are trying to reproduce.

Recommended repair: move the cached lookup into a separate Spring bean and inject it into the orchestration service. Both calls then cross an explicit bean boundary. AspectJ weaving is another mechanism, with different setup; do not casually assume it is enabled. For class proxies, final methods and private methods also constrain interception. Explain the actual proxy and visibility before recommending an annotation change. Proxy limitations

How should a cache key identify the result?

Official fact: the default generator uses an empty key for no arguments, a qualifying single argument directly, and a compound key for multiple arguments. Equality and hash behavior matter. The generator does not automatically give each method an independent namespace inside the same named cache. Default key generation, SimpleKeyGenerator API

Our original lookup contract includes tenant, product, and currency:

public record PriceKey(
    String tenantId,
    long productId,
    String currency
) {}

A method that accepts this immutable key can use key = "#p0". The indexed form avoids depending on compiler-retained parameter names. Validate and normalize the components before calling the lookup; deciding whether usd and USD are equivalent belongs to the application's contract.

If an annotation instead uses only product ID, tenant A's product 42 and tenant B's product 42 collide. A hit can then return a perfectly valid value for the wrong request. This is more serious than a low hit rate. The key must distinguish every input that changes the cached result, including hidden context if the method reads it.

Do not solve authorization by adding a user ID mechanically. Establish which result is safe to reuse, where authorization runs, and whether authorization still happens on a cache hit. A cached public price and a permission-filtered customer view need different contracts.

Original collision exercise: two methods share prices, both take one string, and one returns a quote while the other returns a discount description. Equal string arguments can address the same key. Separate cache names or deliberate method-specific keys prevent that ambiguity. Mutable key objects introduce another hazard if their equality-relevant fields change after insertion.

What observations prove that the repair works?

Reset the cache and repository counter between each original experiment. Verify returned values as well as counts; a collision can look like an efficient cache.

ExperimentExpected repository loadsWhat the observation tests
External proxy calls find twice with one PriceKey1Proxy interception and reuse
External call to unannotated twice with internal find calls2Self-invocation bypass
Separate lookup bean, orchestration calls it twice1Explicit interception boundary
Tenant A then tenant B, same product and currency2Tenant distinction in the complete key
Repeat tenant B's identical lookup0 additionalReuse after correct separation

These are predictions under the stated setup, not measurements from a Spring application run here. In your local reproduction, obtain the bean from a real application context, inspect the configured cache, and record the invocation path. A test that calls only a hand-built object cannot establish proxy behavior.

If the expected hit does not occur, inspect key values, cache names, cache manager identity, eviction, and expiry before changing business logic. If the expected values differ between tenants but the counter stays at one, inspect the collision first.

How do condition, unless, and CachePut differ?

Official fact: condition controls whether caching applies based on the invocation context. unless can veto storing a result after execution and can inspect #result. @CachePut executes the method and stores its result rather than skipping execution on a hit. Cacheable API, CachePut API

For an original synchronous lookup, unless = "#result == null" can express “do not retain a missing price.” It does not guarantee that the method runs when a non-null entry already satisfies the lookup. Conversely, a condition that evaluates false means the invocation proceeds without the ordinary cache reuse behavior.

Ask whether negative results should be cached. Avoiding them permits a newly created product to appear promptly, but repeated misses can overload the repository. A short provider-specific negative-cache policy might be appropriate if the consistency contract permits it. State the trade-off rather than calling either choice universally correct.

For a write method, @CachePut is useful only if its returned object is the exact value the read cache expects, with the same key contract. Returning an update acknowledgment while placing it into a price cache is a type and meaning mismatch. Combining put and cacheable behavior on one method also needs careful justification; simpler separate read and write operations are easier to explain.

Does cache eviction mean the transaction committed?

Official fact: ordinary @CacheEvict defaults to eviction after successful method invocation. beforeInvocation = true moves eviction before execution, independently of the method outcome. allEntries = true clears the cache region rather than one computed key. CacheEvict API

Successful method return is not a sufficient description of database commit timing. Suppose an annotated inner method returns normally inside a larger transaction that later rolls back. The cache effect and transaction outcome need deliberate coordination. Also inspect advice ordering when transaction and cache interceptors wrap the same call.

Spring provides a transaction-aware cache decorator whose ordinary put, evict, and clear operations can defer until a successful transaction's after-commit phase. It documents exceptions for immediate operations such as putIfAbsent and evictIfPresent. Do not generalize that all cache operations are transactionally deferred. TransactionAwareCacheDecorator API

Transaction-bound listeners can also react after commit; that is the default phase of @TransactionalEventListener. This is a coordination tool, not evidence of durable delivery to every application instance. Transaction-bound events

Recommended answer: identify the transaction boundary, explain whether cache effects can precede rollback, and decide what delivery guarantee invalidation requires. If losing an invalidation during a crash is unacceptable, an in-memory listener alone is not the complete solution.

How can an old read refill the cache after eviction?

Even after-commit eviction does not automatically stop an older computation from publishing its result. Use this original schedule with one shared key, initially uncached, and a database price of 10:

  1. Reader R misses the cache and reads price 10.
  2. R pauses before publishing its result.
  3. Writer W commits price 12.
  4. W evicts the key after commit.
  5. R resumes and caches its old price 10.

The next lookup can hit 10 although eviction occurred after the write. This is a reasoning counterexample, not a claim that every provider implements this exact schedule.

Engineering inference: repairs must address publication ordering or explicitly tolerate bounded staleness. Options include versioned values with an atomic reject-older-write rule, a shared coordination protocol covering both readers and writers, or a cache policy appropriate to the product's freshness requirement. A version field by itself does nothing unless the cache update compares it atomically. A lock local to one JVM does not coordinate every application instance.

TTL limits how long an incorrect entry may persist; it does not prevent the race or provide immediate read-after-write consistency. Ask which reader must observe which write, within what time, before choosing a repair.

Does sync=true prevent every cache race?

No. Officially, sync = true requests synchronization of same-key loading through the provider. It is a provider-dependent hint, with restrictions: one cache, no unless, and no combined cache operation on that method. Cacheable synchronization contract

Use it to discuss concurrent miss duplication, then ask about its actual scope. It does not itself specify an ordering protocol between database writers and old readers, or prove coordination across a fleet. Keep stampede control separate from update consistency.

Storage configuration also matters: a local map cache and a configured provider can have different expiry, capacity, and null-value behavior. Configure and verify those policies through the provider rather than inventing a TTL attribute on @Cacheable. Cache storage configuration

Sources and Further Reading