Skip to content

Cache Package

Redis-based caching layer with graceful degradation and flexible TTL configuration for Promenade Platform.


Features

  • Multiple Adapters: Redis (production), NoOp (fallback/testing)
  • Factory Pattern: Auto-selects adapter based on configuration
  • Flexible TTL: Per-resource type configuration (countries, currencies, users, etc.)
  • Graceful Degradation: Continues working when Redis unavailable
  • Cache-Aside Pattern: Read-through cache with write-through invalidation
  • Type-Safe: JSON marshaling for complex types
  • Context-Aware: Proper context propagation for cancellation

Quick Start

1. Configuration

Add cache section to config/app.{env}.yaml:

yaml
# Development (shorter TTLs)
cache:
  enabled: true
  adapter: "redis"  # or "noop" for testing
  prefix: "promenade:dev:"
  default_ttl: "5m"
  ttl:
    countries: "1h"
    currencies: "1h"
    languages: "1h"
    timezones: "1h"
    user_profile: "15m"
    customer: "10m"
    session: "30m"

# Production (longer TTLs)
cache:
  enabled: true
  adapter: "redis"
  prefix: "promenade:prod:"
  default_ttl: "10m"
  ttl:
    countries: "24h"
    currencies: "24h"
    languages: "24h"
    timezones: "24h"
    user_profile: "30m"
    customer: "20m"
    session: "1h"

2. Initialize Cache

go
import (
    "github.com/basilex/promenade/pkg/cache"
    "github.com/redis/go-redis/v9"
)

// Parse config
cacheConfig, err := cfg.Cache.ToCacheConfig()
if err != nil {
    log.Fatal("Failed to parse cache config:", err)
}

// Create Redis client (separate DB for cache)
redisClient := redis.NewClient(&redis.Options{
    Addr:     cfg.Database.Redis.Addr,
    Password: cfg.Database.Redis.Password,
    DB:       cfg.Database.Redis.Databases.Cache, // DB 2 for cache
    PoolSize: 10,
})

// Initialize cache (factory selects Redis or NoOp)
cacheClient, err := cache.NewCache(cacheConfig, redisClient)
if err != nil {
    log.Fatal("Failed to initialize cache:", err)
}
defer cacheClient.Close(context.Background())

3. Use in UseCase

go
type useCase struct {
    repo  IRepository
    cache cache.ICache
}

func NewUseCase(repo IRepository, cacheClient cache.ICache) IUseCase {
    return &useCase{
        repo:  repo,
        cache: cacheClient,
    }
}

// Cache TTL constant (1 hour for reference data)
const cacheTTL = 1 * time.Hour

func (uc *useCase) GetByID(ctx context.Context, id uuid.UUID) (*Entity, error) {
    // Try cache first
    cacheKey := fmt.Sprintf("entity:id:%s", id.String())
    var entity Entity
    err := uc.cache.Get(ctx, cacheKey, &entity)
    
    if err == nil {
        logger.FromContext(ctx).Debug("Cache hit", slog.String("key", cacheKey))
        return &entity, nil
    }
    
    if !cache.IsCacheMiss(err) {
        logger.FromContext(ctx).Error("Cache error", slog.Any("error", err))
    }
    
    // Cache miss - fetch from database
    logger.FromContext(ctx).Debug("Cache miss", slog.String("key", cacheKey))
    entity_, err := uc.repo.GetByID(ctx, id)
    if err != nil {
        return nil, err
    }
    
    // Store in cache
    if err := uc.cache.Set(ctx, cacheKey, entity_, cacheTTL); err != nil {
        logger.FromContext(ctx).Error("Failed to cache entity", slog.Any("error", err))
    }
    
    return entity_, nil
}

Cache Interface

go
type Cache interface {
    // Get retrieves a value from cache and unmarshals it into dest
    Get(ctx context.Context, key string, dest interface{}) error

    // Set stores a value in cache with the given TTL
    Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error

    // Delete removes a single key from cache
    Delete(ctx context.Context, key string) error

    // DeletePattern removes all keys matching the pattern (e.g., "user:*")
    DeletePattern(ctx context.Context, pattern string) error

    // Clear removes all keys from cache (use with caution)
    Clear(ctx context.Context) error

    // Health checks if the cache is operational
    Health(ctx context.Context) error

    // Close gracefully shuts down the cache
    Close(ctx context.Context) error
}

Adapters

Redis Adapter

Use Cases: Production, distributed systems

Features:

  • JSON marshaling for complex types
  • SCAN-based pattern deletion (non-blocking)
  • Connection pooling
  • Automatic reconnection

Configuration:

yaml
cache:
  enabled: true
  adapter: "redis"
  prefix: "promenade:prod:"

NoOp Adapter

Use Cases: Testing, cache disabled, Redis unavailable

Behavior:

  • All Get operations return cache miss
  • All Set/Delete operations succeed silently
  • No side effects, no storage

Configuration:

yaml
cache:
  enabled: false
  adapter: "noop"

Cache Patterns

Cache-Aside (Read-Through)

go
func (uc *useCase) GetByID(ctx context.Context, id uuid.UUID) (*Entity, error) {
    // 1. Try cache
    var entity Entity
    err := uc.cache.Get(ctx, cacheKey, &entity)
    if err == nil {
        return &entity, nil // Cache hit
    }
    
    // 2. Fetch from database (cache miss)
    entity_, err := uc.repo.GetByID(ctx, id)
    if err != nil {
        return nil, err
    }
    
    // 3. Store in cache
    uc.cache.Set(ctx, cacheKey, entity_, cacheTTL)
    
    return entity_, nil
}

Write-Through Invalidation

go
func (uc *useCase) Update(ctx context.Context, entity *Entity) error {
    // 1. Update database
    if err := uc.repo.Update(ctx, entity); err != nil {
        return err
    }
    
    // 2. Invalidate cache
    uc.cache.Delete(ctx, fmt.Sprintf("entity:id:%s", entity.ID))
    uc.cache.Delete(ctx, fmt.Sprintf("entity:code:%s", entity.Code))
    uc.cache.Delete(ctx, "entity:list:all")
    
    return nil
}

Pattern-Based Invalidation

go
func (uc *useCase) InvalidateUserCache(ctx context.Context, userID uuid.UUID) error {
    // Invalidate all keys matching pattern
    pattern := fmt.Sprintf("user:%s:*", userID.String())
    return uc.cache.DeletePattern(ctx, pattern)
}

Key Naming Conventions

Follow consistent naming patterns for easy invalidation:

{entity}:id:{uuid}          - by ID lookup
{entity}:code:{code}        - by code lookup
{entity}:name:{name}        - by name lookup
{entity}:list:all          - full list
{entity}:list:{filter}     - filtered list

Examples:

country:id:01JGABC-1234-5678-9012-ABCDEF123456
country:code:US
country:list:all

currency:id:01JGABC-5678-1234-9012-FEDCBA654321
currency:code:USD
currency:list:all

user:id:01JGABC-9012-3456-7890-123456789ABC:profile
user:email:user@example.com

TTL Strategy

Reference Data (rarely changes):

  • Countries, Currencies, Languages, Timezones
  • Dev: 1 hour
  • Prod: 24 hours

User Data (changes frequently):

  • User profiles, Customers
  • Dev: 10-15 minutes
  • Prod: 20-30 minutes

Session Data (short-lived):

  • User sessions, temporary tokens
  • Dev: 30 minutes
  • Prod: 1 hour

Error Handling

Cache Miss Error

go
err := cache.Get(ctx, key, &dest)
if cache.IsCacheMiss(err) {
    // Normal cache miss - fetch from DB
    return fetchFromDB(ctx, key)
}
if err != nil {
    // Redis error - log but continue
    logger.Error("Cache error", slog.Any("error", err))
    return fetchFromDB(ctx, key)
}

Graceful Degradation

go
// Cache errors are logged but don't break requests
if err := cache.Set(ctx, key, value, ttl); err != nil {
    logger.Error("Failed to cache", slog.Any("error", err))
    // Continue - data still fetched from DB
}

Testing

With NoOp Cache

go
func TestUseCase_GetByID(t *testing.T) {
    mockRepo := new(MockRepository)
    mockRepo.On("GetByID", mock.Anything, id).Return(entity, nil)
    
    // NoOp cache always returns cache miss
    uc := NewUseCase(mockRepo, noop.NewNoOpCache())
    
    result, err := uc.GetByID(ctx, id)
    
    assert.NoError(t, err)
    assert.Equal(t, entity, result)
    mockRepo.AssertExpectations(t) // Should call DB
}

With Real Redis (Integration Tests)

go
func TestCache_Integration(t *testing.T) {
    redisClient := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
        DB:   15, // Use separate test DB
    })
    defer redisClient.Close()
    
    cache := redis.NewRedisCache(redisClient, "test:")
    defer cache.Clear(context.Background())
    
    // Test Set/Get
    err := cache.Set(ctx, "key", "value", 1*time.Minute)
    require.NoError(t, err)
    
    var result string
    err = cache.Get(ctx, "key", &result)
    require.NoError(t, err)
    assert.Equal(t, "value", result)
}

Redis Database Layout

Promenade uses separate Redis logical databases for isolation:

DBPurposeKeys Example
0Token Revocationrevoked:{token_id}
1Event Busbus:{topic}:{event_id}
2Application Cachepromenade:prod:country:*
3User Sessionssession:{session_id}

Key Prefix (promenade:dev: or promenade:prod:) prevents conflicts between environments.


Performance

Redis Adapter

Throughput: ~10,000 ops/sec (network-bound)
Latency:

  • Get: ~1-5ms (local Redis)
  • Set: ~1-5ms (local Redis)
  • SCAN: ~10-50ms (depends on keyspace size)

NoOp Adapter

Throughput: Unlimited (in-memory)
Latency: ~0.1μs (no-op)


Best Practices

DO

  • Use constants for TTL in usecases (e.g., const cacheTTL = 1 * time.Hour)
  • Log cache errors but don't fail requests
  • Invalidate related keys on updates (id, code, list)
  • Use context for cancellation and tracing
  • Test with NoOp cache for unit tests

DON'T

  • DON'T cache sensitive data without encryption
  • DON'T use short keys (hard to debug)
  • DON'T ignore cache errors silently
  • DON'T cache everything (only frequently accessed data)
  • DON'T forget to invalidate on updates/deletes

Monitoring

Health Check

go
// Check cache health
err := cache.Health(ctx)
if err != nil {
    log.Error("Cache unhealthy", err)
}

Metrics (Future)

  • Cache hit rate
  • Cache miss rate
  • Average latency
  • Memory usage
  • Eviction count

Troubleshooting

Cache Not Working

  1. Check Redis connection: redis-cli -h localhost -p 6379 ping
  2. Verify config: cache.enabled = true
  3. Check logs for "Cache initialized"
  4. Test health endpoint: GET /health

High Cache Miss Rate

  1. Check TTL configuration (not too short)
  2. Verify key naming consistency
  3. Check if keys are being invalidated too often
  4. Monitor Redis memory usage (eviction policy)

Redis Connection Errors

  1. Verify Redis is running: docker ps | grep redis
  2. Check connection string in config
  3. Test connection: redis-cli -h <host> -p <port> ping
  4. Review firewall rules


Last Updated: December 30, 2025
Status: Production-ready
Maintainer: Promenade Team

Built with Domain-Driven Design and Go