Skip to main content

Basket Service Caching

The Basket service implements a sophisticated caching strategy using Redis and the decorator pattern to achieve high performance while maintaining data consistency.

Architecture

Decorator Pattern

The service uses the decorator pattern to add caching functionality without modifying the base repository implementation:
// Program.cs:27-28
builder.Services.AddScoped<IBasketRepository, BasketRepository>();
builder.Services.Decorate<IBasketRepository, CachedBasketRepository>();
This approach provides:
  • Separation of Concerns: Caching logic is separate from data access logic
  • Flexibility: Easy to enable/disable caching by removing the decorator
  • Testability: Each component can be tested independently
  • Single Responsibility: Each class has one clear purpose

Redis Configuration

Service Registration

Redis is configured using StackExchange.Redis in Program.cs:30-34:
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    //options.InstanceName = "Basket";
});

Connection String

{
  "ConnectionStrings": {
    "Redis": "localhost:6379"
  }
}

Docker Compose Configuration

In production, Redis is typically configured via Docker Compose:
redis:
  image: redis:alpine
  ports:
    - "6379:6379"
  volumes:
    - redis_data:/data

Repository Implementations

Base Repository

BasketRepository.cs provides the core data access logic using Marten/PostgreSQL:
public class BasketRepository(IDocumentSession session)
    : IBasketRepository
{
    public async Task<ShoppingCart> GetBasket(string userName, CancellationToken cancellationToken = default)
    {
        var basket = await session.LoadAsync<ShoppingCart>(userName, cancellationToken);
        return basket is null ? throw new BasketNotFoundException(userName) : basket;
    }

    public async Task<ShoppingCart> StoreBasket(ShoppingCart basket, CancellationToken cancellationToken = default)
    {
        session.Store(basket);
        await session.SaveChangesAsync(cancellationToken);
        return basket;
    }

    public async Task<bool> DeleteBasket(string userName, CancellationToken cancellationToken = default)
    {
        session.Delete<ShoppingCart>(userName);
        await session.SaveChangesAsync(cancellationToken);
        return true;
    }
}

Cached Repository Decorator

CachedBasketRepository.cs wraps the base repository with caching logic:
public class CachedBasketRepository
    (IBasketRepository repository, IDistributedCache cache) 
    : IBasketRepository
{
    public async Task<ShoppingCart> GetBasket(string userName, CancellationToken cancellationToken = default)
    {
        var cachedBasket = await cache.GetStringAsync(userName, cancellationToken);
        if (!string.IsNullOrEmpty(cachedBasket))
            return JsonSerializer.Deserialize<ShoppingCart>(cachedBasket)!;

        var basket = await repository.GetBasket(userName, cancellationToken);
        await cache.SetStringAsync(userName, JsonSerializer.Serialize(basket), cancellationToken);
        return basket;
    }

    public async Task<ShoppingCart> StoreBasket(ShoppingCart basket, CancellationToken cancellationToken = default)
    {
        await repository.StoreBasket(basket, cancellationToken);
        await cache.SetStringAsync(basket.UserName, JsonSerializer.Serialize(basket), cancellationToken);
        return basket;
    }

    public async Task<bool> DeleteBasket(string userName, CancellationToken cancellationToken = default)
    {
        await repository.DeleteBasket(userName, cancellationToken);
        await cache.RemoveAsync(userName, cancellationToken);
        return true;
    }
}

Caching Strategies

Cache-Aside Pattern (Read)

The GetBasket operation implements the cache-aside pattern (CachedBasketRepository.cs:10-19):
  1. Check Cache: First attempts to retrieve from Redis
  2. Deserialize: If found, deserializes and returns immediately
  3. Database Query: If not cached, queries PostgreSQL
  4. Update Cache: Serializes and stores in Redis for future requests
  5. Return Data: Returns the basket to the caller
public async Task<ShoppingCart> GetBasket(string userName, CancellationToken cancellationToken = default)
{
    // Step 1: Check cache
    var cachedBasket = await cache.GetStringAsync(userName, cancellationToken);
    if (!string.IsNullOrEmpty(cachedBasket))
        // Step 2: Return cached data
        return JsonSerializer.Deserialize<ShoppingCart>(cachedBasket)!;

    // Step 3: Query database
    var basket = await repository.GetBasket(userName, cancellationToken);
    
    // Step 4: Update cache
    await cache.SetStringAsync(userName, JsonSerializer.Serialize(basket), cancellationToken);
    
    // Step 5: Return data
    return basket;
}
Performance Benefits:
  • First request: Full database query
  • Subsequent requests: Fast Redis lookups
  • Typical latency reduction: 10-100x

Write-Through Pattern (Update)

The StoreBasket operation implements write-through caching (CachedBasketRepository.cs:21-28):
  1. Write to Database: First updates PostgreSQL
  2. Update Cache: Immediately updates Redis cache
  3. Return Success: Returns the stored basket
public async Task<ShoppingCart> StoreBasket(ShoppingCart basket, CancellationToken cancellationToken = default)
{
    // Step 1: Write to database
    await repository.StoreBasket(basket, cancellationToken);

    // Step 2: Update cache
    await cache.SetStringAsync(basket.UserName, JsonSerializer.Serialize(basket), cancellationToken);

    // Step 3: Return
    return basket;
}
Advantages:
  • Cache is always consistent with database
  • Next read request will be fast (cached)
  • No cache warm-up needed

Cache Invalidation (Delete)

The DeleteBasket operation ensures cache consistency (CachedBasketRepository.cs:30-37):
  1. Delete from Database: Removes from PostgreSQL
  2. Invalidate Cache: Removes from Redis
  3. Return Success: Confirms deletion
public async Task<bool> DeleteBasket(string userName, CancellationToken cancellationToken = default)
{
    // Step 1: Delete from database
    await repository.DeleteBasket(userName, cancellationToken);

    // Step 2: Invalidate cache
    await cache.RemoveAsync(userName, cancellationToken);

    // Step 3: Return
    return true;
}
Cache Invalidation Rules:
  • Always invalidate on delete operations
  • Prevents serving stale data
  • Maintains data consistency

Cache Key Strategy

Key Format

The service uses a simple key format:
{userName}
Examples:
  • "swn" - Basket for user “swn”
  • "john.doe" - Basket for user “john.doe”
  • "customer123" - Basket for user “customer123”

Key Benefits

  • Simple: Easy to understand and debug
  • Unique: Username is unique identifier
  • Direct Access: O(1) lookup complexity
  • Human Readable: Easy to inspect in Redis CLI

Alternative Approaches

For more complex scenarios, consider:
// With instance name prefix
options.InstanceName = "Basket";
// Results in keys like: "Basket:swn"

// With environment prefix
var key = $"{environment}:basket:{userName}";
// Results in keys like: "prod:basket:swn"

Serialization

JSON Serialization

The service uses System.Text.Json for serialization:
// Serialize to cache
var json = JsonSerializer.Serialize(basket);
await cache.SetStringAsync(userName, json, cancellationToken);

// Deserialize from cache
var cachedBasket = await cache.GetStringAsync(userName, cancellationToken);
var basket = JsonSerializer.Deserialize<ShoppingCart>(cachedBasket)!;

Serialization Considerations

Advantages of JSON:
  • Human-readable in Redis
  • Easy to debug
  • Platform-independent
  • Compatible with Redis CLI tools
Performance Optimization: For higher performance, consider binary serialization:
// Using MessagePack (example)
var bytes = MessagePackSerializer.Serialize(basket);
await cache.SetAsync(userName, bytes, cancellationToken);

Cache Expiration

Current Implementation

The current implementation does not set explicit cache expiration:
await cache.SetStringAsync(userName, JsonSerializer.Serialize(basket), cancellationToken);
This means:
  • Cache entries persist until explicitly deleted
  • Checkout operation deletes the cache entry
  • No automatic cleanup of abandoned carts
Add sliding or absolute expiration for better cache management:
// Sliding expiration - extends on each access
var options = new DistributedCacheEntryOptions
{
    SlidingExpiration = TimeSpan.FromHours(24)
};
await cache.SetStringAsync(userName, json, options, cancellationToken);

// Absolute expiration - expires at specific time
var options = new DistributedCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(7)
};
await cache.SetStringAsync(userName, json, options, cancellationToken);

// Combination - sliding with absolute max
var options = new DistributedCacheEntryOptions
{
    SlidingExpiration = TimeSpan.FromHours(24),
    AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(30)
};
await cache.SetStringAsync(userName, json, options, cancellationToken);

Health Checks

Redis health is monitored via health checks (Program.cs:58-60):
builder.Services.AddHealthChecks()
    .AddNpgSql(builder.Configuration.GetConnectionString("Database")!)
    .AddRedis(builder.Configuration.GetConnectionString("Redis")!);
Access health status:
curl http://localhost:5001/health
Response:
{
  "status": "Healthy",
  "entries": {
    "redis": {
      "status": "Healthy",
      "duration": "00:00:00.0098765"
    }
  }
}

Monitoring and Debugging

Redis CLI Commands

Useful commands for monitoring cache:
# Connect to Redis
redis-cli

# View all basket keys
KEYS *

# Get specific basket
GET swn

# Check if key exists
EXISTS swn

# Get TTL (time to live)
TTL swn

# Delete specific basket
DEL swn

# Flush all cache (DANGEROUS)
FLUSHALL

# Get cache statistics
INFO stats

Cache Hit Rate

Monitor cache effectiveness:
redis-cli INFO stats | grep keyspace
Calculate hit rate:
Hit Rate = keyspace_hits / (keyspace_hits + keyspace_misses)

Performance Considerations

Latency Comparison

OperationDatabase (PostgreSQL)Cache (Redis)Improvement
Get Basket10-50ms1-5ms10-50x faster
Store Basket20-100ms15-95msSlightly slower (write-through)
Delete Basket10-30ms8-28msSlightly slower (dual delete)

Best Practices

  1. Cache Warm Baskets: Most accessed baskets stay in cache
  2. Short Serialization: Keep basket size reasonable
  3. Connection Pooling: Redis connections are pooled automatically
  4. Async Operations: All operations are fully asynchronous
  5. Cancellation Support: All methods support cancellation tokens

Failure Scenarios

Redis Unavailable

If Redis is down:
  1. Health check reports unhealthy
  2. Cache operations throw exceptions
  3. Application fails to start (current implementation)
Recommended Enhancement: Implement fallback to database-only mode:
public async Task<ShoppingCart> GetBasket(string userName, CancellationToken cancellationToken = default)
{
    try
    {
        var cachedBasket = await cache.GetStringAsync(userName, cancellationToken);
        if (!string.IsNullOrEmpty(cachedBasket))
            return JsonSerializer.Deserialize<ShoppingCart>(cachedBasket)!;
    }
    catch (Exception ex)
    {
        // Log warning and continue without cache
        logger.LogWarning(ex, "Redis cache unavailable, falling back to database");
    }

    return await repository.GetBasket(userName, cancellationToken);
}

Build docs developers (and LLMs) love