Spring Boot / Intermediate: data, security, and framework evidence / Lesson 11
Caching keys and consistency
Define what may be reused, for whom, and for how long before selecting Spring caching annotations or a provider.
On this page
Lesson goals
- Choose complete cache keys and safe result ownership.
- Explain provider-specific size/expiry behavior.
- Coordinate invalidation with transaction outcomes and security.
A cache changes a freshness contract
Caching reuses a result under a key instead of executing the underlying method every time. Define tolerated staleness, scope, invalidation, memory budget, and failure behavior first. A read-only annotation or a high hit rate does not prove a cached result is safe for every user. Avoid caching sensitive authorization decisions or user-specific data under a key that omits identity and policy context.
Boot can configure cache infrastructure when caching is enabled and an appropriate provider is available. The abstraction does not invent a universal TTL, distributed lock, or atomic database/cache transaction. A simple concurrent-map cache has different behavior from Caffeine or a remote provider. Pin the selected provider's contract and test it rather than assuming annotations erase those differences.
Declare the dimensions actually used by the result
The fragment enables caching in a separate configuration and includes tenant, product, and language dimensions through positional parameter references. This avoids pretending that product id alone identifies every representation. It requires a chosen CacheManager and contextual query capability, and it is not executed. The tenant input must still be derived or checked through trusted authorization.
The default proxy-based caching mode also needs a real managed call boundary; a same-instance call can bypass the advice. A cache hit skips underlying method execution, so essential authorization should not exist only inside work that the hit bypasses. Keep the access decision and result reuse contract deliberately aligned, including revocation and changes to user-visible fields.
package training.catalog;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Service;
@Configuration(proxyBeanMethods = false)
@EnableCaching
class ProductCacheConfiguration {}
@Service
class ProductCacheService {
private final ProductQuery products;
ProductCacheService(ProductQuery products) { this.products = products; }
@Cacheable(cacheNames="productSummaries", key="{#p0,#p1,#p2}")
public ProductSummary load(String tenantId, long productId, String language) {
return products.read(tenantId, productId, language);
}
}
The list key includes all three declared dimensions. Cache provider, interception, authorization, and invalidation behavior remain unexecuted context.
Check key separation without claiming provider behavior
The JDK-only record key model shows that the same product id in another tenant or language is a different key. Its three Boolean results verify that synthetic equality contract. It is not a Spring cache test, an expiry test, or proof that a caller is entitled to the tenant string it supplied. An attacker-selected scope remains unsafe even inside a technically distinct cache key.
Cached values need an ownership rule too. Reusing a mutable list or a managed entity can let one caller affect another's result or retain persistence state unexpectedly. Prefer an approved immutable result or defensive copy where the contract requires it. Negative caching of missing results also needs a freshness policy so later creation is not hidden longer than intended.
public class Main {
record Key(String tenant,long product,String language) {}
public static void main(String[] args) throws Exception {
var cache=java.util.Map.of(new Key("north",42,"en"),"North view");
System.out.println(cache.containsKey(new Key("north",42,"en")));
System.out.println(cache.containsKey(new Key("south",42,"en")));
System.out.println(cache.containsKey(new Key("north",42,"ar")));
}
}
true
false
false
Only the identical tenant/product/language key matches. No cache provider, TTL, proxy, or authorization mechanism is run.
Align invalidation with actual committed change
Eviction after a method invocation is not automatically proof it occurred after a database commit. Cache and transaction advice ordering, failure, and provider behavior matter. A transaction-aware decorator or explicit after-commit workflow can coordinate selected operations, but does not make a distributed cache and database one atomic resource. Test rollback and commit failure as well as ordinary updates.
Define which keys a mutation invalidates, how other instances learn about it, and what happens if invalidation delivery fails. Avoid clearing every cache globally as a routine cure for one stale value. sync options and per-process coordination do not imply one cluster-wide lock. Cache stampedes, expensive misses, and expiry bursts need bounded work and provider-aware measurements.
Measure usefulness and stale-data consequences
Test key collisions, tenant/language separation, mutable results, negative results, update invalidation, rollback, expiry, capacity, and backend failure. A mock CacheManager cannot prove a remote provider's serialization or distributed behavior. Record hit/miss and latency under comparable workloads, but retain business correctness and access denial checks even when the hit ratio looks excellent.
No remote cache service is started here. Keep provider-specific configuration illustrative until validated in an approved isolated environment. A useful cache improves measured work while respecting a stated staleness and security contract. If nobody can explain when a result is refreshed or who may reuse it, adding @Cacheable has hidden a design problem rather than solved a performance one.
Common errors and fixes
A tenant received another tenant's cached DTO
- Why it happens
- The cache key or reuse authorization omitted scope.
- How to fix it
- Repair trusted access and complete the key dimensions.
- Prevent it next time
- Test valid cross-tenant identifiers.
A simple cache never expired entries
- Why it happens
- Provider defaults were assumed to supply TTL or capacity.
- How to fix it
- Choose and verify provider-specific limits.
- Prevent it next time
- Document expiry and memory policy.
Rollback left a misleading cached value
- Why it happens
- Cache mutation and database completion were not coordinated.
- How to fix it
- Use a reviewed transaction-aware/after-commit policy and test failures.
- Prevent it next time
- Include rollback and commit-time rejection cases.
Check your understanding
1. Why does product id42 alone make a poor key for tenant-local translated summaries?
Show worked solution
The same id can identify different tenant data or another language representation. Include every result-defining dimension and enforce trusted scope independently. A complete equality key does not authorize a caller to choose another tenant.
2. Does a successful direct load call prove Cacheable interception?
Show worked solution
No. It can prove underlying method behavior. The caching contract needs a managed proxy call, selected provider, key checks, and hit/miss/invalidation evidence. Self-invocation or manual construction can bypass advice while still returning a correct-looking DTO.
Key takeaways
- A cache is a scoped freshness and ownership contract.
- Provider behavior is not supplied universally by annotations.
- Transaction outcome and authorization remain necessary on cache paths.