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 value
  • joinCacheName – The secondary cache providing the joined data
  • joinKeyExpression – A SpEL expression to extract the join key from the first value
  • joinKeyExtractor – 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:

  • K1 and V1 represent the key and value types of the first cache
  • K2 and V2 represent 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:

  1. Type Extraction – Resolves the four generic type arguments from the interface declaration using ResolvableType
  2. Validation – Ensures the target interface actually extends JoinCache and that cache names are non-blank
  3. Metadata Construction – Builds a JoinCacheMetadata object 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:

  1. Scans for all interfaces annotated with @JoinCacheable
  2. Invokes JoinCacheMetadataParser.toJoinCacheMetadata() to extract configuration
  3. Registers a JoinCacheProxyFactoryBean definition 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:

  1. The factory obtains a JoinCacheProxyFactory instance (the default implementation is DefaultJoinCacheProxyFactory provided by the core module)
  2. Invokes create(metadata) to generate a dynamic proxy implementing the annotated interface
  3. 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 JoinValue object

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

  • @JoinCacheable marks a Kotlin interface as a join cache, specifying the first cache, join cache, and key extraction strategy.
  • JoinCacheMetadataParser transforms annotation attributes into runtime metadata objects containing type information and configuration.
  • EnableCoCacheRegistrar automatically registers Spring bean definitions for each join cache interface during context initialization.
  • JoinCacheProxyFactoryBean creates dynamic proxies that implement the join cache interface and coordinate dual cache access.
  • SpringJoinKeyExtractorFactory resolves 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →