Stage 7 · Master
Phase 15 — Redis Integration
Cache Invalidation
Let each service invalidate only the cache keys it owns, and replace unsafe SCAN-based deletion of list caches with a versioned key that expires on its own.
The Writer Invalidates Its Own Keys — No Exceptions
resident-service is the only writer of resident data, so it is the only service that ever calls DEL on a resident:* key. Payment or Complaint never reach into resident-service's cache namespace directly, even if they happen to know a resident changed — they call resident-service's own API and let it handle its own cache the same way they'd let it handle its own database.
// Invalidate deletes every cache entry this service owns for one
// resident. It is called from the write path — UpdateProfile, MoveOut —
// immediately after the PostgreSQL transaction commits, never before.
func (c *CachedResidentReader) Invalidate(ctx context.Context, organizationID, residentID string) error {
return c.redis.Del(ctx,
cacheKey(organizationID, residentID),
).Err()
}Invalidate runs after commit, not inside the same transaction — if the transaction later rolled back, deleting the cache first would have discarded a still-valid cached value for no reason.
Organization Service already established that the owning module is the only writer of its own state. Redis keys are state too — they just don't have a foreign key or a WHERE clause reminding you whose they are.
SCAN and DEL Do Not Belong in a Request Path
A single resident's cache key is easy to delete directly. A cached list — 'all active residents in flat 4B' — is harder: a naive approach might SCAN for every key matching a pattern and delete them, but SCAN is not guaranteed to return a fully consistent snapshot under concurrent writes, and running it synchronously inside a request path adds real latency proportional to keyspace size, not to the data actually being invalidated.
package cacheadapter
import (
"context"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
type FlatResidentListCache struct {
redis *redis.Client
ttl time.Duration
}
func NewFlatResidentListCache(client *redis.Client, ttl time.Duration) *FlatResidentListCache {
return &FlatResidentListCache{redis: client, ttl: ttl}
}
func versionKey(organizationID, flatID string) string {
return "residents:" + organizationID + ":" + flatID + ":list:version"
}
func listKey(organizationID, flatID string, version int64) string {
return "residents:" + organizationID + ":" + flatID + ":list:v" + strconv.FormatInt(version, 10)
}
// currentVersion never fails the caller on a miss — an unset version key
// simply means version 1, the first time this flat's list is cached.
func (c *FlatResidentListCache) currentVersion(ctx context.Context, organizationID, flatID string) int64 {
version, err := c.redis.Get(ctx, versionKey(organizationID, flatID)).Int64()
if err != nil {
return 1
}
return version
}
// Bump advances the version, which makes every previously cached list key
// for this flat unreachable by construction — no DEL, no SCAN required.
// The orphaned old-version key simply expires on its own TTL.
func (c *FlatResidentListCache) Bump(ctx context.Context, organizationID, flatID string) error {
return c.redis.Incr(ctx, versionKey(organizationID, flatID)).Err()
}
func (c *FlatResidentListCache) Key(ctx context.Context, organizationID, flatID string) string {
return listKey(organizationID, flatID, c.currentVersion(ctx, organizationID, flatID))
}Bump is O(1) regardless of how large the cached list itself is or how many stale versions currently exist — the cost of invalidation no longer scales with keyspace size, which is exactly the property SCAN-based deletion lacks.
Move-Out Bumps the Version; It Does Not Delete Anything
package application
import "context"
type ListCacheBumper interface {
Bump(ctx context.Context, organizationID, flatID string) error
}
func (u *MoveOutResident) afterCommit(ctx context.Context, organizationID, flatID, residentID string) {
// Best-effort: a failed Bump means one flat's list cache serves a
// slightly stale view until its TTL naturally expires — a tolerable
// staleness window, not a correctness bug, given the entity-level
// cache from the previous lesson is still eagerly invalidated.
_ = u.listCache.Bump(ctx, organizationID, flatID)
_ = u.residentCache.Invalidate(ctx, organizationID, residentID)
}The comment states the tolerance explicitly: a stale list for one TTL window is an accepted tradeoff, not an oversight — because the individual resident's own record, which matters more for correctness, is still deleted eagerly and immediately.
Confirm Old List Keys Become Unreachable, Not Deleted
Applied exercise
Extend versioning to organization-wide directory search
The organization directory search endpoint caches its top results per search term, and a teammate wants those invalidated too whenever any resident's name changes.
- Decide whether one version counter per organization is sufficient, or whether it needs to be per search term.
- State the tradeoff of a coarser (organization-wide) versus finer-grained (per search term) version key.
- Identify what write operations must call Bump for this new cache.
- Explain why deleting matching keys with SCAN would still be the wrong choice here, even for a smaller keyspace.
Deliverable
A short written design choosing a version granularity and listing the write paths that must call Bump.
Completion checks
- The chosen granularity is justified against its tradeoff, not just asserted.
- The explanation of why SCAN is still wrong references the request-path latency or consistency argument from this lesson, not just 'best practice'.
Why does Bump increment a version counter instead of the write path calling redis-cli's SCAN-based pattern delete on every list key for the flat?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.