Skip to content

ADR-022: Redis Distributed Caching and Caffeine Local Fallback Strategy

  • Date: 2026-09-06
  • Status: Accepted
  • Phase: Phase 8C

Context & Problem Statement

Payment processing backends experience high-frequency, repetitive read queries for user profiles, UPI routing records, and recent balance ledger summaries. In standard database-backed flows:

  1. Every payment transfer checks sender and receiver balances, account statuses, and KYC limits.
  2. User dashboards and transaction polling trigger repetitive lookups against users and balance_ledger tables.
  3. Hitting PostgreSQL for read-heavy lookups consumes database connection pool permits (HikariCP), adds disk I/O latency, and competes with write transactions (SELECT ... FOR UPDATE row locks).

In distributed multi-node production clusters, an in-memory-only cache causes cache incoherency (node A updates balance, but node B serves stale balance from local memory). Conversely, requiring a live Redis server for local development and unit/integration testing introduces heavyweight infrastructure dependencies and slows down developer iteration cycles.

Additionally, caching JPA domain entities introduces serialization hazards:

  • Default Java native serialization is vulnerable to remote code execution (RCE) and requires class bytecode version matching.
  • Jackson JSON serialization on Hibernate entities fails on uninitialized lazy proxies (hibernateLazyInitializer, handler) or causes infinite recursion loops across bidirectional entity graphs.
  • Caching empty lookups (Optional.empty()) pollutes the cache with null values or causes deserialization errors.

Considered Options

  1. Direct Database Reads Only (No Caching): Eliminates cache invalidation complexity, but subjects PostgreSQL to severe read contention under high transaction volumes (500+ TPS), degrading transfer latency.
  2. In-Memory Caffeine Cache Only (Across All Profiles): Ultra-fast in-process caching with zero network hops, but produces cache inconsistency in multi-pod Kubernetes deployments where pods do not share state.
  3. Redis Cache Only (Across All Profiles): Centralized distributed cache, but forces local developers and CI unit/slice tests to run a live Redis container or daemon, harming developer productivity.
  4. Profile-Conditional Hybrid Architecture: Redis in prod, Caffeine in !prod (Chosen):
  5. In production (@Profile("prod")), uses Spring Data Redis (RedisCacheManager backed by Lettuce) with standalone host/port configuration.
  6. In non-production environments (@Profile("!prod"): local, test, prod-light), falls back to Caffeine (CaffeineCacheManager) with bounded memory size (1,000 entries) and 10-minute expiration.
  7. Spring Cache abstraction (@Cacheable, @CacheEvict) provides unified annotations with zero domain coupling to the underlying cache provider.

Decision Outcome

Chosen Option: Profile-Conditional Hybrid Architecture (Option 4)

Architectural Design & Invalidation Policy

1. Cache Names & Granular TTL Configuration

Cache Name Key Strategy Default TTL Eviction Policy Purpose
users Single key: #id, #referenceId, #upiId 10 minutes (600s) All entries evicted on registerUser() and sendMoney() Hot user profile & UPI handle resolution
user_ledgers Compound key: #userReferenceId + '_' + #pageable.pageNumber 1 minute (60s) All entries evicted on sendMoney() Recent double-entry audit history pages

2. Modern JSON Serialization Strategy

  • Keys are serialized using RedisSerializer.string().
  • Values are serialized using the modern Spring Data Redis RedisSerializer.json(), replacing the deprecated GenericJackson2JsonRedisSerializer.
  • Value serialization is human-readable, schema-portable, and immune to Java native deserialization exploits.

3. Entity Hardening for Jackson Serialization

  • User and BalanceLedgerEntry implement java.io.Serializable with serialVersionUID = 1L.
  • BalanceLedgerEntry is annotated with @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) to prevent Jackson from attempting to serialize uninitialized Hibernate lazy proxies.
  • @JsonIgnore is applied to lazy associations (user, transaction) on BalanceLedgerEntry to eliminate circular reference graphs during JSON marshaling.

4. Null-Safe Cache-Aside Semantics

  • Read operations (getUserById, getUserByReferenceId, findByUpiId, getUserByUpiId, getUserLedger) use @Cacheable(..., unless = "#result == null").
  • Spring's CacheAspectSupport unwraps Optional<User> before SpEL condition evaluation; the #result == null check ensures empty Optional.empty() results are never persisted to the cache.

5. Write Eviction & Consistency

  • UserService.registerUser() triggers @CacheEvict(value = "users", allEntries = true).
  • TransactionService.sendMoney() triggers @CacheEvict(value = {"users", "user_ledgers"}, allEntries = true) upon successful transfer commit, guaranteeing stale balances and ledger records are purged immediately.

Consequences

  • Positive:
  • Sub-millisecond lookup response times on repeat user and ledger queries.
  • Significant reduction (>80%) in PostgreSQL connection pool and read query pressure.
  • 100% zero-dependency local startup with Caffeine in local and test profiles.
  • Secure, human-readable JSON payloads in Redis without native Java serialization risks.
  • Negative / Trade-offs:
  • Using allEntries = true invalidates all cached user/ledger entries on balance mutations, trading short-term cache hit rates for absolute data consistency. In future phases, key-targeted eviction (#senderUpi, #receiverUpi) can be evaluated once secondary indexing is established.