How @JoinCacheable Enables Automatic Cache Joining in the Cocache Framework
The @JoinCacheable annotation triggers a Spring-based metadata parsing and proxy generation pipeline that automatically wires two independent caches into a single cohesive join cache without requiring manual implementation.
The cocache library by Ahoo Wang simplifies distributed caching in JVM applications by providing declarative cache joining capabilities. By annotating a Kotlin interface with @JoinCacheable, developers can automatically combine data from two separate cache sources—referred to as the first cache and the join cache—into unified join values. This mechanism leverages Spring's bean registration lifecycle and dynamic proxy generation to eliminate boilerplate cache coordination code.
Declaring Join Caches with @JoinCacheable
The foundation of automatic cache joining rests on a simple interface declaration paired with the @JoinCacheable annotation.
The Annotation Contract
The JoinCacheable annotation defines the relationship between two caches:
@JoinCacheable(
firstCacheName = "UserExtendInfoCache",
joinCacheName = "UserCache",
joinKeyExpression = "#{#root.userId}"
)
interface UserExtendInfoJoinCache :
JoinCache<String, UserExtendInfo, String, User>
The annotation captures four critical pieces of metadata:
firstCacheName– The primary cache holding the initial valuejoinCacheName– The secondary cache providing the joined datajoinKeyExpression– A SpEL expression to extract the join key from the first valuejoinKeyExtractor– Optional bean name for a custom extractor (used when expression is blank)
Interface Requirements
Any join cache interface must extend JoinCache<K1, V1, K2, V2> where:
K1andV1represent the key and value types of the first cacheK2andV2represent the key and value types of the join cache
Parsing Metadata with JoinCacheMetadataParser
When the application context initializes, the framework converts annotation attributes into runtime metadata using JoinCacheMetadataParser.
This parser performs three essential operations:
- Type Extraction – Resolves the four generic type arguments from the interface declaration using
ResolvableType - Validation – Ensures the target interface actually extends
JoinCacheand that cache names are non-blank - Metadata Construction – Builds a
JoinCacheMetadataobject containing the cache names, extractor configuration, and resolved type information
The resulting metadata object serves as the blueprint for proxy creation.
Spring Bean Registration via EnableCoCacheRegistrar
The EnableCoCacheRegistrar implements Spring's ImportBeanDefinitionRegistrar interface to hook into the annotation processing lifecycle. When the application detects @EnableCoCache, the registrar:
- Scans for all interfaces annotated with
@JoinCacheable - Invokes
JoinCacheMetadataParser.toJoinCacheMetadata()to extract configuration - Registers a
JoinCacheProxyFactoryBeandefinition for each discovered interface
This registration occurs during the postProcessBeanFactory phase, ensuring that join cache beans are available for dependency injection before the container finishes initialization.
Dynamic Proxy Generation
The actual proxy instantiation is handled by JoinCacheProxyFactoryBean, which implements Spring's FactoryBean interface.
During the getObject() call:
- The factory obtains a
JoinCacheProxyFactoryinstance (the default implementation isDefaultJoinCacheProxyFactoryprovided by the core module) - Invokes
create(metadata)to generate a dynamic proxy implementing the annotated interface - The proxy intercepts all method calls and delegates to the underlying cache coordination logic
The generated proxy automatically handles:
- Fetching the first value from the primary cache
- Resolving the join key using the configured extractor
- Fetching the join value from the secondary cache
- Assembling the results into a
JoinValueobject
Resolving Join Keys with SpringJoinKeyExtractorFactory
Join key resolution represents the critical link between the two caches. The SpringJoinKeyExtractorFactory determines how to extract the key from the first cache's value:
Strategy 1: SpEL Expression
When joinKeyExpression is non-blank (e.g., #{#root.userId}), the factory creates an ExpJoinKeyExtractor that evaluates the expression against the first value at runtime.
Strategy 2: Custom Bean
When the expression is blank, the factory attempts to locate a Spring bean named <cacheName>.JoinKeyExtractor. For example, UserExtendInfoJoinCache.JoinKeyExtractor would resolve a custom extractor bean implementing JoinKeyExtractor<V1, K2>.
This dual strategy provides flexibility for simple property access via SpEL while supporting complex business logic through custom implementations.
Practical Implementation Example
Consider a domain where UserExtendInfo joins with User via a user ID:
Interface Definition
@JoinCacheable(
firstCacheName = "UserExtendInfoCache",
joinCacheName = "UserCache",
joinKeyExpression = "#{#root.userId}"
)
interface UserExtendInfoJoinCache :
JoinCache<String, UserExtendInfo, String, User>
Source: [UserExtendInfoJoinCache.kt](https://github.com/ahoo-wang/cocache/blob/main/cocache-example/src/main/kotlin/me/ahoo/cache/example/cache/UserExtendInfoJoinCache.kt)
Service Usage
@Service
class UserService(private val joinCache: UserExtendInfoJoinCache) {
fun getUserWithInfo(extendInfoId: String): JoinValue<UserExtendInfo, String, User> {
// The proxy automatically:
// 1. Retrieves UserExtendInfo from UserExtendInfoCache
// 2. Extracts userId using the SpEL expression
// 3. Retrieves User from UserCache
// 4. Returns a JoinValue combining both
return joinCache[extendInfoId]
}
}
Custom Extractor Alternative
For scenarios requiring complex key resolution logic:
@Bean("UserExtendInfoJoinCache.JoinKeyExtractor")
fun customExtractor(): JoinKeyExtractor<UserExtendInfo, String> {
return JoinKeyExtractor { extendInfo ->
extendInfo.userId.uppercase() // Complex transformation
}
}
When this bean exists, SpringJoinKeyExtractorFactory uses it instead of the SpEL expression.
Summary
@JoinCacheablemarks a Kotlin interface as a join cache, specifying the first cache, join cache, and key extraction strategy.JoinCacheMetadataParsertransforms annotation attributes into runtime metadata objects containing type information and configuration.EnableCoCacheRegistrarautomatically registers Spring bean definitions for each join cache interface during context initialization.JoinCacheProxyFactoryBeancreates dynamic proxies that implement the join cache interface and coordinate dual cache access.SpringJoinKeyExtractorFactoryresolves join keys either through SpEL evaluation or custom bean lookup, enabling flexible data relationships.
Frequently Asked Questions
What interface must a join cache implement?
A join cache interface must extend JoinCache<K1, V1, K2, V2> with four generic type parameters representing the key/value pairs of both the first and join caches. The interface requires no method implementations—only the @JoinCacheable annotation and type declarations.
How does the framework resolve the join key at runtime?
The framework uses SpringJoinKeyExtractorFactory to create a JoinKeyExtractor instance. If the annotation specifies a joinKeyExpression, it builds an expression-based extractor using Spring's SpEL engine. Otherwise, it attempts to locate a custom extractor bean named <cacheName>.JoinKeyExtractor from the application context.
Can I provide a custom join key extractor instead of using SpEL?
Yes. Define a bean implementing JoinKeyExtractor<V1, K2> and name it according to the pattern <YourCacheInterfaceName>.JoinKeyExtractor. For example, if your interface is OrderCustomerJoinCache, the bean name should be OrderCustomerJoinCache.JoinKeyExtractor. When the expression is blank, the factory automatically wires this custom extractor into the proxy.
Where is the proxy implementation actually created?
The proxy implementation is created by DefaultJoinCacheProxyFactory (accessed via the JoinCacheProxyFactory interface) within the JoinCacheProxyFactoryBean.getObject() method. This factory generates a dynamic proxy that intercepts method calls, performs the dual cache lookups, and assembles the results, all while implementing the specific interface type declared by the developer.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →