Skip to content
AITroveRead. Build. Understand.
Make this comfortable

Spring cache keys: separate tenants and test the loader count

Last updated: 30 Sept 20264 min read
tutorial
IntermediateBy AITrove Editorial

A cache key identifies a stored result, so omitting an input that changes the result can return another caller’s data.

Download Spring source kit

This lesson uses the downloadable source kit: Java 21, Spring Boot 4.0.8 and its managed Spring Framework 7 dependencies. The version is pinned for repeatable builds.

Include the ownership dimension

The lookup receives tenant and receipt identifier. Its cache key contains both, so tenant-a receipt seven and tenant-b receipt seven occupy different entries. The test calls tenant-a twice and tenant-b once, then checks that the target loader ran exactly twice.

The public responses alone would not prove caching: a loader could produce the same strings every time. Counting actual target calls supplies the missing evidence without timing guesses or sleep-based assertions.

Local caching does not solve invalidation

ConcurrentMapCacheManager is local to this context and does not define expiry, capacity or cross-instance consistency. A growing set of identifiers can grow memory without a bound. A production cache needs a retention rule and a stale-data policy.

Cached values should not be mutable entity graphs that callers can change behind the cache’s back. This fixture caches immutable strings. Proxy self-invocation can also bypass cache advice, so inspect the call path rather than relying on the annotation text.

Checked source

Java
package in.aitrove.learning;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ReceiptCacheBoundary {
    public static class Lookup {
        final AtomicInteger calls = new AtomicInteger();
        @Cacheable(cacheNames="receipts", key="#p0 + ':' + #p1")
        public String read(String tenant, long receiptId) { calls.incrementAndGet(); return tenant + ":" + receiptId; }
    }
    @Configuration(proxyBeanMethods=false) @EnableCaching
    public static class Config {
        @Bean ConcurrentMapCacheManager caches() { return new ConcurrentMapCacheManager("receipts"); }
        @Bean Lookup lookup() { return new Lookup(); }
    }
    public static void main(String[] args) {
        try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class)) {
            Lookup lookup = context.getBean(Lookup.class);
            System.out.println(lookup.read("tenant-a", 7));
            System.out.println(lookup.read("tenant-a", 7));
            System.out.println(lookup.read("tenant-b", 7));
        }
    }
}

Test the boundary

Run mvn test in the source-kit directory. CoreBoundaryTest.cacheSeparatesTenantsAndAvoidsRepeatedLoader checks the behavior described here. Java excerpts belong to the named source-kit classes; they are not independent source files unless the complete class is shown.

Costs and boundaries

The example’s map retains one entry per distinct tenant-and-identifier pair. No eviction is configured. This makes correctness visible but does not supply a production memory budget.

Common Mistakes

  • Do not omit tenant or other result-changing inputs from the key.
  • Do not treat a local cache as distributed invalidation.
  • Set retention and stale-data rules before production use.

Read next

Spring AOP proxies: self-invocation bypasses proxy advice, Java ConcurrentHashMap: atomic updates and weakly consistent reads, Java collection factories: rejected updates and shallow element ownership.

Extend the tested workflow

Continue with Spring cache eviction: invalidate the same tenant key used by the read, Java LinkedHashMap LRU cache: access order changes eviction.

Continue with checked Spring boundaries

Continue with Spring @Cacheable sync: collapse a local same-key cache miss.

spring
spring-boot
cache-keys
Storage details