java 83 lines · 3 tabs

Time-Based Caffeine Cache for Expensive Currency Rate Lookups in Spring

Shared by codesnips Jul 2026
3 tabs
import com.github.benmanes.caffeine.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.math.BigDecimal;
import java.util.concurrent.TimeUnit;

@Configuration
public class RateProviderConfig {

    @Bean
    public LoadingCache<CurrencyPair, BigDecimal> rateCache(FxRateClient client) {
        CacheLoader<CurrencyPair, BigDecimal> loader = key -> client.fetchRate(key);

        return Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(5, TimeUnit.MINUTES)
                .refreshAfterWrite(2, TimeUnit.MINUTES)
                .recordStats()
                .build(loader);
    }
}
3 files · java Explain with highlit

This snippet shows how an expensive remote lookup — foreign-exchange rates from a rate-limited upstream provider — is fronted by a Caffeine cache with a time-based eviction policy. The pattern applies whenever a value is costly to compute or fetch, changes slowly, and can tolerate being slightly stale in exchange for far fewer upstream calls.

In RateProviderConfig, a LoadingCache<CurrencyPair, BigDecimal> is built declaratively. The key settings are expireAfterWrite(5, MINUTES), which fixes a hard TTL so every entry is refetched at least once per window, and refreshAfterWrite(2, MINUTES), which triggers an asynchronous reload while continuing to serve the old value. That distinction matters: expireAfterWrite blocks the requesting thread when an entry is fully evicted, whereas refreshAfterWrite keeps latency flat by returning the stale value and refreshing in the background. maximumSize caps memory so an unbounded set of currency pairs cannot exhaust the heap, and recordStats() exposes hit/miss metrics for tuning. The CacheLoader delegates to the real fetcher, and build wires the loader in so get transparently populates misses.

ExchangeRateService is what the rest of the application calls. Its getRate method calls cache.get(pair), which returns instantly on a hit and only invokes the upstream client on a miss. Caffeine guarantees the loader runs at most once per key even under concurrent access, so a burst of requests for the same pair collapses into a single upstream call rather than a thundering herd. Any checked exception from the loader surfaces as an unchecked CompletionException, which is unwrapped here into a domain-specific RateUnavailableException. The stats method surfaces the recorded counters for observability.

FxRateClient is the genuinely expensive collaborator — an HTTP call with parsing — kept deliberately unaware of caching so it stays testable in isolation. A subtle pitfall worth noting: refreshAfterWrite reloads on the next access after the interval, not on a timer, so a key that is never read again will still expire via expireAfterWrite rather than being refreshed forever. Choosing the refresh interval shorter than the expiry keeps hot keys warm while cold keys are eventually dropped.


Related snips

Share this code

Here's the card — post it anywhere.

Time-Based Caffeine Cache for Expensive Currency Rate Lookups in Spring — share card
Link copied