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:
- Every payment transfer checks sender and receiver balances, account statuses, and KYC limits.
- User dashboards and transaction polling trigger repetitive lookups against
usersandbalance_ledgertables. - Hitting PostgreSQL for read-heavy lookups consumes database connection pool permits (
HikariCP), adds disk I/O latency, and competes with write transactions (SELECT ... FOR UPDATErow 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¶
- 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.
- 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.
- 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.
- Profile-Conditional Hybrid Architecture: Redis in
prod, Caffeine in!prod(Chosen): - In production (
@Profile("prod")), uses Spring Data Redis (RedisCacheManagerbacked by Lettuce) with standalone host/port configuration. - 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. - 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 deprecatedGenericJackson2JsonRedisSerializer. - Value serialization is human-readable, schema-portable, and immune to Java native deserialization exploits.
3. Entity Hardening for Jackson Serialization¶
UserandBalanceLedgerEntryimplementjava.io.SerializablewithserialVersionUID = 1L.BalanceLedgerEntryis annotated with@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})to prevent Jackson from attempting to serialize uninitialized Hibernate lazy proxies.@JsonIgnoreis applied to lazy associations (user,transaction) onBalanceLedgerEntryto 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
CacheAspectSupportunwrapsOptional<User>before SpEL condition evaluation; the#result == nullcheck ensures emptyOptional.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
localandtestprofiles. - Secure, human-readable JSON payloads in Redis without native Java serialization risks.
- Negative / Trade-offs:
- Using
allEntries = trueinvalidates 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.