Why Spring Cache Falls Short for Bulk Operations
Spring’s cache abstraction is intentionally simple. "one method call maps to one cache key". That simplicity works well for most CRUD style methods, until you hit bulk APIs.
Consider a very common service method:
fun findUsers(ids: List<Long>): List<User>
At first glance, it looks like a perfect candidate for @Cacheable. In practice, this is where the abstraction starts to leak.
The Core Problem: One Invocation, Many Keys
Spring Cache annotations (@Cacheable, @CachePut, @CacheEvict) model caching as:
- one method invocation → one cache key → one cache value
That assumption breaks down for bulk methods.
Naive approach: composite keys
@Cacheable("users")
fun findUsers(ids: List<Long>): List<User>
This usually results in a single cache entry:
key = users::[1,2,3]
value = [User(1), User(2), User(3)]
This approach comes with several drawbacks:
- No reuse for partial requests, a request for
[2,3]results in a cache miss even if[1,2,3]is already cached. - Order-sensitive keys,
[1,2]and[2,1]produce different cache entries unless explicitly normalized. - Large cache entries, values grow with the size of the request.
- Complex invalidation, evicting or updating individual items becomes difficult.
In effect, the cache ends up storing queries rather than individual data entities.
What Redis Can Do ( But the Spring Annotation Can’t Express )
Redis itself has no such limitation. It natively supports:
- Multi-key reads (
MGET) - Pipelining
- Efficient bulk operations
The gap is not in Redis, but in the cache abstraction layer. Spring Cache annotations are designed around a single-key model and do not provide a bulk-aware contract.
Specifically, the abstraction does not account for:
- Partial cache hits
- Mapping multiple cache keys to a single method invocation
- Merging cached and freshly loaded results
As a result, implementing true bulk caching typically means either scattering manual cache logic throughout the codebase or introducing a higher level abstraction. Instead of pushing bulk caching logic into every service method, we chose to make it explicit and reusable.
@BulkCacheable is a custom annotation we designed to represent bulk caching as a first-class concern, rather than an implementation detail.
Defining the Contract: What @BulkCacheable Should Do
The behavior we want is straightforward:
- Extract individual keys from method arguments
- Read all keys from the cache in one operation
- Detect missing keys
- Invoke the target method only for missing data
- Write missing results back to the cache
- Return a merged result set
Conceptually:
Request IDs: [1,2,3,4]
Cache Hit : [1,2]
Cache Miss : [3,4]
DB Call : findUsers([3,4])
Result : merge([1,2] + [3,4])
In practice, the annotation can carry additional metadata, such as how cache keys are constructed, how long entries should live, and how identifiers are extracted from returned elements. These details are intentionally kept declarative, allowing the interceptor to remain generic while avoiding hard-coded assumptions in service code.
The important point is not the specific parameters, but the separation of concerns: service methods describe what should be cached in bulk, while the interception layer decides how that behavior is applied.
Bringing the Annotation Contract to Life
@BulkCacheable(
cacheName = "users",
keyPrefix = "users:",
ttlSeconds = 86400
)
fun findUsers(ids: List<Long>): List<User>
Simply annotating a method with @BulkCacheable does not alter its execution on its own. The annotation only becomes relevant once Spring detects it and decides to intercept the method call.
To do that, Spring wraps the target bean in a proxy. From that point on, calls to the method no longer go directly to the implementation but are routed through an interception layer where bulk caching logic can be applied.
At runtime, the call flow effectively looks like this:
Caller
↓
Spring Proxy
↓
BulkCacheableInterceptor
↓
Target Method
The proxy is responsible for intercepting the invocation, inspecting the method arguments, reading annotation metadata via reflection, and determining whether the target method needs to be executed at all or whether cached data can satisfy the request.
Inside the @BulkCacheable Interceptor (Aspect)
A simplified interceptor flow looks like this:
@Around("@annotation(bulkCacheable)")
fun around(
joinPoint: ProceedingJoinPoint
): Any {
// 1. Extract bulk keys from arguments
// 2. Read annotation metadata from the intercepted method
// 3. Perform a bulk cache lookup
// 4. Identify missing keys
// 5. Call the target method only for missing keys
// 6. Write missing results back to cache
// 7. Merge and return
}
Key implementation details:
- Method signatures are inspected at runtime to extract bulk keys from invocation arguments
- Annotation metadata is resolved reflectively from the intercepted method
- Cache lookups are performed using Redis multi-key (
multiGet) operations to minimize round trips - Results are carefully merged to preserve ordering and behavioral semantics
Trade-offs and Constraints with @BulkCacheable
@BulkCacheable is designed to simplify bulk caching, but it is not universally applicable. In scenarios where cached data is rarely reused or where cache invalidation rules are particularly complex, the additional abstraction may not provide meaningful value.
Similarly, when strict ordering guarantees or consistency requirements outweigh performance concerns, an explicit caching strategy can be easier to reason about. In such cases, keeping bulk caching logic visible rather than implicit may lead to clearer and more maintainable code.
Final Thoughts
Spring Cache solves the common case well, but bulk access patterns require a different abstraction. @BulkCacheable builds on Redis’s bulk capabilities and Spring’s interception model to make those patterns explicit and reusable. Once the proxy model is understood, custom annotations are no longer mysterious and they are simply another way to structure behavior cleanly.

