Stage 7 · Master
Phase 15 — Redis Integration
Cache Aside Pattern
Measure the resident lookup's real latency under load before reaching for Redis, then wrap the existing repository in a cache-aside decorator instead of rewriting it.
A Cache Is a Cost, Not a Default
Adding Redis to every read path 'because caching is good practice' trades a small, well-understood PostgreSQL query for a second system that can now be stale, can now be down, and now needs its own invalidation rules. Resident-service's profile lookup joins residents, flats, and occupancy — a real join, run on every request — which makes it a reasonable first candidate to actually measure rather than assume.
package postgresadapter_test
import (
"context"
"testing"
)
// BenchmarkGetResidentProfile hits the real joined query directly against
// a seeded test database — 5,000 residents across 40 organizations — with
// b.RunParallel to approximate concurrent request load.
func BenchmarkGetResidentProfile(b *testing.B) {
pool := openBenchPool(b)
repo := newResidentRepository(b, pool)
orgID, residentID := seededLookupTarget(b, pool)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if _, err := repo.GetProfile(context.Background(), orgID, residentID); err != nil {
b.Fatal(err)
}
}
})
}b.RunParallel is what makes this a load benchmark rather than a single-threaded timing loop — the join's cost under contention is the number that actually matters for a production decision.
BenchmarkGetResidentProfile-8 4213 284117 ns/op 312 B/op 9 allocs/op284 microseconds per lookup, entirely inside the join, before any application logic runs. At the organization's peak resident-app usage — several hundred profile views per second during morning checkout — this join alone would consume a visible share of the database's available connections.
Introducing Redis on the strength of 'joins feel slow' would be exactly the kind of premature complexity this course avoids elsewhere. A concrete, reproducible benchmark is what turns a caching decision into an engineering decision.
The Client Already Exists — What It Needs Is a Timeout Budget
Redis has been in the compose file since Phase 1, and both the gateway limiter and the auth denylist already hold connections to it. What none of them needed until now is a read path on the critical request line, which makes one setting decisive: a cache lookup that blocks for two seconds has made every request slower than the query it was meant to replace. The constructor below therefore fixes short dial and read timeouts, and treats a timeout as a miss.
package redisx
import (
"context"
"errors"
"github.com/redis/go-redis/v9"
)
type Config struct {
Addr string
Password string
DB int
}
func NewClient(ctx context.Context, cfg Config) (*redis.Client, error) {
if cfg.Addr == "" {
return nil, errors.New("redis address is required")
}
client := redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Password: cfg.Password,
DB: cfg.DB,
})
if err := client.Ping(ctx).Err(); err != nil {
return nil, err
}
return client, nil
}Ping at startup means a misconfigured address fails the service at boot, in a readable way, instead of failing silently on the first cached request in production.
Wrap the Repository Instead of Rewriting It
The cache-aside layer implements the exact same interface the resident-service handler already depends on, and wraps the real repository rather than replacing it. On a cache hit it never touches PostgreSQL; on a miss it reads through to the real repository and populates the cache before returning.
package cacheadapter
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
"github.com/hoa-platform/backend/services/resident-service/internal/domain"
)
type ResidentReader interface {
GetProfile(ctx context.Context, organizationID, residentID string) (domain.ResidentProfile, error)
}
type CachedResidentReader struct {
next ResidentReader
redis *redis.Client
ttl time.Duration
}
func NewCachedResidentReader(next ResidentReader, client *redis.Client, ttl time.Duration) *CachedResidentReader {
return &CachedResidentReader{next: next, redis: client, ttl: ttl}
}
func cacheKey(organizationID, residentID string) string {
return "resident:" + organizationID + ":" + residentID
}
func (c *CachedResidentReader) GetProfile(ctx context.Context, organizationID, residentID string) (domain.ResidentProfile, error) {
key := cacheKey(organizationID, residentID)
if cached, err := c.redis.Get(ctx, key).Bytes(); err == nil {
var profile domain.ResidentProfile
if unmarshalErr := json.Unmarshal(cached, &profile); unmarshalErr == nil {
return profile, nil
}
// A corrupt cache entry falls through to the real repository rather
// than returning bad data or an error the caller didn't ask for.
}
profile, err := c.next.GetProfile(ctx, organizationID, residentID)
if err != nil {
return domain.ResidentProfile{}, err
}
if data, marshalErr := json.Marshal(profile); marshalErr == nil {
c.redis.Set(ctx, key, data, c.ttl)
}
return profile, nil
}The cache key is namespaced by organization_id before resident_id — the same tenant-scoping discipline from every PostgreSQL query in this course applies just as strictly to Redis keys, which have no WHERE clause to enforce it for you.
c.redis.Set's error is intentionally ignored here — a resident should still get their profile even if Redis is briefly unreachable. The request's correctness depends on PostgreSQL; Redis is only ever a latency optimization on top of it.
Confirm the Benchmark Actually Moved
BenchmarkGetResidentProfile_Cached-8 198442 6034 ns/op 248 B/op 6 allocs/opRoughly a 47x improvement on a warm cache hit — the number that justifies the added operational surface of running Redis in production for this specific read path.
Applied exercise
Decide whether the flat lookup deserves the same treatment
A teammate proposes caching the flat-detail endpoint the same way, without first benchmarking it.
- Write the benchmark you would run against flat-service's repository before writing any cache code.
- State what result would justify adding a cache-aside layer, and what result would not.
- If justified, name the exact cache key you would use and explain its tenant-scoping.
- If not justified, write the one-sentence reason you would give the teammate.
Deliverable
A written benchmark plan (or the actual benchmark code) plus a clear go/no-go decision with its justification.
Completion checks
- The decision is backed by a stated threshold, not a general preference for or against caching.
- The cache key design, if proposed, follows the organization-first namespacing convention.
Why does CachedResidentReader fall through to the real repository on a JSON unmarshal error instead of returning that error to the caller?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.