package com.example.fx.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.time.Duration;
@Configuration
@EnableCaching
@EnableScheduling
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager("fxRates");
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats());
return manager;
}
}
package com.example.fx.service;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.math.BigDecimal;
@Service
public class CurrencyRateService {
private final RestClient restClient;
public CurrencyRateService(RestClient.Builder builder) {
this.restClient = builder.baseUrl("https://api.example-fx.com").build();
}
@Cacheable(value = "fxRates", key = "#from + '-' + #to", unless = "#result == null")
public BigDecimal getRate(String from, String to) {
return fetchRateFromProvider(from, to);
}
@CacheEvict(value = "fxRates", allEntries = true)
@Scheduled(fixedRate = 300_000)
public void refreshAllRates() {
// Cache cleared proactively; next reads repopulate lazily.
}
private BigDecimal fetchRateFromProvider(String from, String to) {
RateResponse response = restClient.get()
.uri("/latest?base={base}&symbols={sym}", from, to)
.retrieve()
.body(RateResponse.class);
if (response == null || response.rate() == null) {
return null;
}
return response.rate();
}
public record RateResponse(String base, String symbol, BigDecimal rate) {
}
}
package com.example.fx.web;
import com.example.fx.service.CurrencyRateService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.math.RoundingMode;
@RestController
public class ConversionController {
private final CurrencyRateService rateService;
public ConversionController(CurrencyRateService rateService) {
this.rateService = rateService;
}
@GetMapping("/convert")
public ResponseEntity<ConversionResult> convert(
@RequestParam String from,
@RequestParam String to,
@RequestParam BigDecimal amount) {
BigDecimal rate = rateService.getRate(from.toUpperCase(), to.toUpperCase());
if (rate == null) {
return ResponseEntity.status(502).build();
}
BigDecimal converted = amount.multiply(rate).setScale(2, RoundingMode.HALF_UP);
return ResponseEntity.ok(new ConversionResult(from, to, amount, rate, converted));
}
public record ConversionResult(String from, String to, BigDecimal amount,
BigDecimal rate, BigDecimal result) {
}
}
This snippet shows how a Spring Boot service caches expensive foreign-exchange rate lookups so that repeated conversions do not hammer a slow, rate-limited upstream provider. The pattern is Spring's declarative caching abstraction: methods annotated with @Cacheable transparently store their return value keyed by the arguments, and subsequent calls with the same key skip the method body entirely and return the cached value.
In CacheConfig, @EnableCaching activates the annotation processing, and a Caffeine-backed CacheManager bounds the cache with maximumSize and a expireAfterWrite TTL. Bounding matters: an unbounded cache of currency pairs would grow forever and a stale rate that never expires would be worse than a cache miss. The write-based expiry means a rate is trusted for five minutes regardless of read traffic, which is a sensible freshness window for FX quotes.
In CurrencyRateService, getRate carries @Cacheable(value = "fxRates", key = "#from + '-' + #to"). The explicit SpEL key composes a stable cache key from both arguments so that USD-EUR and EUR-USD never collide. The unless = "#result == null" guard prevents a failed or empty lookup from being cached, which avoids pinning a bad value for the whole TTL. The actual fetchRateFromProvider call is the expensive part being protected — a remote HTTP round trip.
The refreshAllRates method is annotated with @CacheEvict(value = "fxRates", allEntries = true) and driven by @Scheduled, so every five minutes the entire cache is flushed proactively. Combining a TTL with scheduled eviction gives two independent freshness mechanisms and a clean way to force-clear on demand.
In ConversionController, the endpoint simply multiplies an amount by the cached rate; it is unaware of caching, which is the whole point of the abstraction — cross-cutting concerns stay out of business logic. A key pitfall to remember is that @Cacheable and @CacheEvict rely on Spring's proxy, so self-invocation within the same bean bypasses the cache; calls must cross the proxy boundary to take effect. This approach fits any read-heavy lookup with an expensive, idempotent source and tolerable staleness.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
Share this code
Here's the card — post it anywhere.