How to Implement a Custom CacheSource for Database-Backed Cache Loading in CoCache

Implement the CacheSource<K, V> interface (and optionally TtlConfigurationAware), expose the implementation as a Spring bean whose name ends with .CacheSource, and CoCache will automatically invoke loadCacheValue(key) on cache misses to fetch data from your database.

CoCache is a high-performance, multi-level caching framework for JVM applications that combines client-side and distributed stores. When a cache miss occurs, the framework delegates to a CacheSource to load the missing value from an external system. By default, CoCache uses a no-op implementation, but you can create a custom CacheSource for database-backed cache loading in CoCache to seamlessly integrate with JPA, MyBatis, or JDBC repositories.

Understanding the CacheSource Interface Contract

The core abstraction for loading data is the CacheSource<K, V> interface defined in cocache-api/src/main/kotlin/me/ahoo/cache/api/source/CacheSource.kt. This contract requires only a single method:

@Throws(TimeoutException::class)
fun loadCacheValue(key: K): CacheValue<V>?

The method receives the cache key and returns a CacheValue<V> wrapper containing the loaded entity, or null if the key does not exist in the underlying store. If the operation times out, it should throw TimeoutException to signal the failure.

When no custom implementation is provided, CoCache falls back to NoOpCacheSource, located at cocache-api/src/main/kotlin/me/ahoo/cache/api/source/NoOpCacheSource.kt. This default implementation always returns null, effectively disabling automatic cache loading.

Implementing a Database-Backed CacheSource

To fetch data from a relational database, create a class that implements CacheSource and optionally TtlConfigurationAware to respect the TTL configuration defined in your @CoCache annotation.

Creating the Implementation Class

The following example demonstrates a complete implementation using Spring Data JPA. This pattern mirrors the CustomizeUserCacheSource found in the CoCache test suite at cocache-spring-boot-starter/src/test/kotlin/me/ahoo/cache/spring/boot/starter/customize/EnableCoCacheConfigurationWithCustomize.kt.

package com.example.cache

import me.ahoo.cache.api.CacheValue
import me.ahoo.cache.api.source.CacheSource
import me.ahoo.cache.TtlConfigurationAware
import me.ahoo.cache.TtlConfiguration
import org.springframework.stereotype.Component
import java.util.concurrent.TimeoutException

@Component
class UserCacheSource(
    private val userRepository: UserRepository
) : CacheSource<String, User>, TtlConfigurationAware {

    private var ttlConfig: TtlConfiguration? = null

    @Throws(TimeoutException::class)
    override fun loadCacheValue(key: String): CacheValue<User>? {
        val user = userRepository.findById(key).orElse(null) ?: return null
        return CacheValue.of(user, ttlConfig?.ttl?.toMillis())
    }

    override fun setTtlConfiguration(ttlConfiguration: TtlConfiguration) {
        this.ttlConfig = ttlConfiguration
    }
}

Handling TTL Configuration

By implementing TtlConfigurationAware, your source receives the TTL duration configured via the @CoCache annotation (e.g., ttl = "30s"). The setTtlConfiguration callback occurs during cache initialization, allowing you to store the TTL and apply it to the CacheValue returned from loadCacheValue. This ensures consistency between the database-loaded value and the cache expiration policy.

Querying the Database

Inside loadCacheValue, integrate with your persistence layer using JPA, MyBatis, or raw JDBC. Wrap the resulting entity in CacheValue.of(value, ttlMillis) to preserve both the payload and its time-to-live. Return null for missing keys to indicate a cache miss, allowing CoCache to handle the negative caching strategy according to your configuration.

Registering the Source as a Spring Bean

When using the Spring Boot starter, CoCache automatically discovers your implementation via SpringCacheSourceFactory, located at cocache-spring/src/main/kotlin/me/ahoo/cache/spring/source/SpringCacheSourceFactory.kt.

The factory resolves beans by type using the generic parameters K and V from your CacheSource implementation. Crucially, the bean name must end with the suffix .CacheSource (defined as CACHE_SOURCE_SUFFIX in the factory). In the example above, the class name UserCacheSource satisfies this convention. Alternatively, you can explicitly name the bean:

@Bean("userCacheSource.CacheSource")
fun userCacheSource(repo: UserRepository) = UserCacheSource(repo)

If no matching bean is found, the factory's fallback() method returns CacheSource.noOp(), ensuring the application starts without errors but without automatic loading capabilities.

How CoCache Integrates Your Custom Source

The core cache implementation, CoherentCache in cocache-core/src/main/kotlin/me/ahoo/cache/consistency/CoherentCache.kt, holds a reference to your CacheSource. When a get(key) operation results in a miss across all cache levels, CoCache invokes loadCacheValue(key).

If your implementation returns a non-null CacheValue, CoCache performs the following actions:

  1. Stores the value in the client-side cache (e.g., Caffeine or Guava)
  2. Propagates the value to the distributed cache (e.g., Redis) if configured
  3. Returns the value to the caller

This mechanism ensures that subsequent requests for the same key are served from the fast in-memory or distributed tiers, while your database remains the authoritative source of truth.

Summary

  • Implement CacheSource<K, V> to define how missing keys are loaded from your database or external store.
  • Optionally implement TtlConfigurationAware to respect the TTL settings defined in @CoCache annotations.
  • Register as a Spring bean with the .CacheSource suffix so SpringCacheSourceFactory can discover and inject your implementation.
  • Return CacheValue from loadCacheValue to populate both client-side and distributed caches, or return null for missing keys.
  • Reference implementation: Study CustomizeUserCacheSource in the CoCache test suite for a production-ready pattern.

Frequently Asked Questions

What happens if my CacheSource throws a TimeoutException?

If loadCacheValue throws TimeoutException, CoCache propagates the error to the caller rather than caching a null value. This prevents stale data from being stored when the database is temporarily unavailable. You should implement retry logic or circuit breakers within your source implementation if transient failures are expected.

Can I use multiple CacheSource implementations for different entity types?

Yes. CoCache resolves the correct CacheSource based on the generic type parameters K (key type) and V (value type). Each cache defined with @EnableCoCache can have its own dedicated source bean, provided the generic types match the cache declaration. For example, a UserCache using Cache<String, User> will receive the CacheSource<String, User> bean.

Do I need to handle caching the loaded value myself?

No. Once your loadCacheValue method returns a CacheValue, the CoherentCache implementation automatically handles writing to the client-side cache and the distributed cache (if configured). You only need to focus on fetching the data from your database; CoCache manages the storage and expiration logic.

What is the performance impact of implementing a custom CacheSource?

The performance depends entirely on your database query efficiency. CoCache invokes loadCacheValue synchronously on the calling thread during a cache miss, so slow queries will block the request. Consider adding timeouts, asynchronous preloading, or query optimization to minimize latency. The framework itself adds minimal overhead beyond the method invocation.

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 →