All Articles

Getting started with Caffeine - An introduction to caching in Kotlin

Caffeine
Caffeine just wakes your application up!

Earlier, we have covered requests and the storage of the relevant information in databases. Now we will introduce another booster of performance - caching!

This article will cover the setup of Caffeine caches in your Kotlin project with spring-cache. A follow-up post will cover more advanced techniques.

Introduction

Caching and its importance

Caching is a technique of storing frequently used data in a temporary storage area, called cache, so that it can be quickly accessed without the need to recalculate or fetch the data from its original source.

This is important because it helps to reduce the load on the system and improve the performance of applications by reducing the number of times data has to be retrieved from a slower or more distant source. Caching is particularly important in systems that have high traffic, limited resources, or slow data retrieval times. Additionally, it can be applied to various levels of an application, from the client-side to the database level.

These advantages are the main ones that stand out to me:

  • Improved performance: Caching can significantly reduce the time it takes to retrieve data, making the application faster and more responsive to user requests.
  • Reduced load on the system: Caching reduces the number of requests that need to be made to the original data source, reducing the load on the system and improving its overall stability.
  • Increased scalability: Caching allows an application to handle more traffic and users without adding more resources to the system, making it more scalable.
  • Offline access: Caching allows data to be stored temporarily, so that it can still be accessed even when the original data source is unavailable, such as during an internet outage.

Different types of caching

There are so many different types of caching. Here are a couple of them:

  • Client-side caching: This type of caching stores data on the client’s device
  • Server-side caching: This type of caching stores data on the server, such as in memory
  • Database caching: This type of caching stores data in the database, such as in a separate table or in-memory store
  • Content delivery network (CDN) caching: This type of caching stores data on a network of servers that are distributed geographically, so that it can be quickly delivered to users regardless of their location
  • Distributed caching: This type of caching stores data on multiple servers, so that it can be accessed quickly and easily, even in a distributed system.

This article will cover server-side caching with caffeine. In the future, I may write another one about distributed caching.

Caffeine as a caching library

Caffeine is a high-performance caching library for Java and Kotlin. It is designed to be easy to use, yet powerful enough to handle large-scale caching needs. Caffeine provides a simple, fluent API for creating and managing caches, as well as advanced features such as loading and eviction policies, expiration, and statistics.

Caffeine is an in-memory caching library, which means that it stores data in the application’s heap memory. This allows for very fast access to cached data, but it also means that the cache is limited by the amount of available memory. Caffeine also provides support for off-heap storage and disk persistence, so you can configure your cache to use disk storage if memory becomes constrained.

One of the key benefits of using Caffeine is that it is highly configurable. You can specify the maximum size of the cache, how long entries should be retained, and how entries should be evicted when the cache reaches its maximum size. This makes it easy to tune your cache for the specific needs of your application.

Caffeine also provides rich statistics and metrics about the cache, such as hit rate, eviction count, and average load penalty. This allows you to monitor the performance of your cache and make adjustments as needed.

Overall, Caffeine is a powerful and flexible caching library that is well-suited for a wide range of use cases. It’s easy to use, highly configurable, and provides rich statistics and metrics.

First cache setup

Let’s define what we’ll cache. I think that, currently, the operation that is most likely to occur frequently would be the currency exchange. While it’s true that we have that in the DB, it would then reduce the load on that layer.

Also, this would show how we’re really caching a Kotlin function, independently of what’s underneath that.

Installation and configuration

First, like always, we need to add Caffeine as a library to the project in our pom.xml.

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>LATEST_VERSION</version>  <!-- not required in springboot -->
</dependency>

Next, we create a Configuration class called CaffeineConfig:

import com.github.benmanes.caffeine.cache.Caffeine
import org.springframework.cache.CacheManager
import org.springframework.cache.caffeine.CaffeineCache
import org.springframework.cache.support.SimpleCacheManager
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class CaffeineConfig {

    @Bean
    fun caffeineCacheManager(): CacheManager {
        val cacheManager = SimpleCacheManager()
        val caches = listOf()
        cacheManager.setCaches(caches)
        return cacheManager
    }

Creating a basic cache

Looking at the configuration above, you can already guess where caches will go. Let’s define one and add it to the cache manager.

  // Cache names
    companion object {
          const val FX_CACHE = "FX_CACHE"
    }
      
    fun fxCache(): CaffeineCache {
        return CaffeineCache(FX_CACHE, Caffeine.newBuilder().maximumSize(5).build())
    }
  
  // CacheManager becomes:
    @Bean
    fun caffeineCacheManager(): CacheManager {
        val cacheManager = SimpleCacheManager()
        val caches = listOf(
            fxCache()
        )
        cacheManager.setCaches(caches)
        return cacheManager
    }

Using the cache in your application

There are two ways to already use the cache defined above. One is by interacting with the CacheManager directly, the other is by annotating the function.

Using function annotation

The easiest way to add the cache is to simply annotate the function. However, if we go this way, please ensure that you’ve let the application know we’re going to do some caching, by adding the @EnableCaching annotation on some Configuration class. Now, the usage:

@Component
class CurrencyService @Inject constructor(
    private val currencyRepository: CurrencyRepository,
    private val currencyGateway: CurrencyGateway
) {

    fun getConversionRate(currency: Currency): Double {
        return currencyRepository.findById(LocalDate.now()).toNullable()?.currencyRates?.get(currency.currencyCode)
            ?: storeConversionRates().currencyRates[currency.currencyCode]
            ?: throw CurrencyNotFoundException()
    }
}

Great! Let’s test this out. I am actually only going to show the integration testing in the next article.

However, I’ve started the application, and when I run the same endpoint twice, we only get to the breakpoint inside the function to cache once. Wonderful!

Using CacheManager

If we go this route, it’s slightly different and more verbose. However, we may have more flexibility:

@Component
class CurrencyService @Inject constructor(
    private val currencyRepository: CurrencyRepository,
    private val currencyGateway: CurrencyGateway,
    private val cacheManager: CacheManager
) {

    @Transactional
    fun storeConversionRates(): CurrencyExchange {
        val rates = currencyGateway.getRates().collectList().block()!![0]
        currencyRepository.save(rates)
        return rates
    }

    //    @Cacheable(FX_CACHE)
    fun getConversionRate(currency: Currency): Double {
        val cache = cacheManager.getCache(FX_CACHE)
        val cachedRate = cache?.get(currency.currencyCode)
        if (cachedRate != null) {
            return cachedRate as Double
        }
        val rate = currencyRepository.findById(LocalDate.now()).toNullable()?.currencyRates?.get(currency.currencyCode)
            ?: storeConversionRates().currencyRates[currency.currencyCode]
            ?: throw CurrencyNotFoundException()
        cache?.put(currency.currencyCode, rate)
        return rate
    }
}

As you can see, this is definitely more verbose. However, we have the possibility to include a lot more logic into how we want to cache. This can, at times, be very useful also.

Well, now you have some basic options on how to perform caching and make your application blazingly fast! In the next article, we will dive into how we can make even more improvements and use the flexibility Caffeine offers us! Stay tuned!