☀️ 🌙

Learning Redis with C# Minimal API

This document covers everything we've discussed: Redis fundamentals, TTL, cache versioning, invalidation, EF Core behaviors, and a full working example. permalink: "posts/redis-example/index.html"

1. What is Redis?

Redis (Remote Dictionary Server) is an in-memory key-value data store designed for high performance.
It can act as a:

Key benefits:


2. Using Redis in C# 12 (.NET 8)

Installation

dotnet add package StackExchange.Redis

Run Redis locally via Docker:

docker run --name redis -p 6379:6379 redis:latest

3. Cache Versioning

When your data model changes, cached JSON may become incompatible with your updated code.
Solution → use a cache version prefix.

Example

const string CacheVersion = "v2";  // bump this when the model changes
string cacheKey = $"{CacheVersion}:user:{userId}";
await db.StringSetAsync(cacheKey, json, TimeSpan.FromMinutes(10));

4. TTL (Time-To-Live)

TTL defines how long a key should live in Redis before being automatically removed.

Example

db.StringSet("user:1", "Alice", TimeSpan.FromMinutes(10));

You can check TTL:

var ttl = db.KeyTimeToLive("user:1");
Console.WriteLine(ttl?.TotalSeconds);

5. Cache Invalidation

When the database changes, invalidate (delete) the Redis cache to avoid serving stale data.

Example

await db.KeyDeleteAsync($"{CacheVersion}:product:{product.Id}");

Flow after invalidation:

  1. First GET → MISS → fetches from DB → re-caches.
  2. Second GET → HIT → serves from Redis.
  3. TTL eventually expires → next GET refreshes cache automatically.

6. Full Minimal API Example (ASP.NET 8 + C# 12)

Program.cs

using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);

// EF Core with in-memory database
builder.Services.AddDbContext<AppDbContext>(opt => opt.UseInMemoryDatabase("Products"));

// Redis ConnectionMultiplexer as Singleton
builder.Services.AddSingleton<IConnectionMultiplexer>(
    _ => ConnectionMultiplexer.Connect("localhost:6379"));

var app = builder.Build();

// Seed DB
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    if (!db.Products.Any())
    {
        db.Products.AddRange(
            new Product { Id = 1, Name = "Laptop", Price = 1000m },
            new Product { Id = 2, Name = "Phone", Price = 500m }
        );
        db.SaveChanges();
    }
}

const string CacheVersion = "v1";

// GET: Product
app.MapGet("/products/{id:int}", async (int id, AppDbContext dbContext, IConnectionMultiplexer redis) =>
{
    var cacheKey = $"{CacheVersion}:product:{id}";
    var db = redis.GetDatabase();

    // Try cache first
    var cached = await db.StringGetAsync(cacheKey);
    if (cached.HasValue)
    {
        Console.WriteLine($"Cache HIT: {cacheKey}");
        return Results.Json(JsonSerializer.Deserialize<Product>(cached!));
    }

    // DB fallback
    var product = await dbContext.Products.FindAsync(id);
    if (product is null) return Results.NotFound();

    await db.StringSetAsync(cacheKey, JsonSerializer.Serialize(product), TimeSpan.FromMinutes(5));
    Console.WriteLine($"Cache MISS: {cacheKey} -> Stored in Redis");

    return Results.Json(product);
});

// PUT: Product (update + invalidate cache)
app.MapPut("/products/{id:int}", async (int id, Product updated, AppDbContext dbContext, IConnectionMultiplexer redis) =>
{
    var product = await dbContext.Products.FindAsync(id);
    if (product is null) return Results.NotFound();

    // Update tracked entity directly
    product.Name = updated.Name;
    product.Price = updated.Price;

    await dbContext.SaveChangesAsync();

    // Invalidate cache
    var cacheKey = $"{CacheVersion}:product:{id}";
    var db = redis.GetDatabase();
    await db.KeyDeleteAsync(cacheKey);

    Console.WriteLine($"Cache invalidated: {cacheKey}");
    return Results.Ok(product);
});

app.Run();

// EF Core Models
public class Product
{
    public int Id { get; set; }               // PK by EF Core convention
    public string Name { get; set; } = "";
    public decimal Price { get; set; }
}

class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();
}

7. Why Use AddSingleton for IConnectionMultiplexer

builder.Services.AddSingleton<IConnectionMultiplexer>(
    _ => ConnectionMultiplexer.Connect("localhost:6379"));

8. EF Core & Primary Keys


9. Complete Cache Lifecycle

sequenceDiagram
    participant Client
    participant API
    participant Redis
    participant DB

    Client->>API: GET /products/1
    API->>Redis: Check cache
    Redis-->>API: MISS
    API->>DB: Fetch product
    DB-->>API: Product
    API->>Redis: Store with TTL
    API-->>Client: Return product

    Client->>API: GET /products/1
    API->>Redis: Check cache
    Redis-->>API: HIT
    API-->>Client: Return product

    Client->>API: PUT /products/1
    API->>DB: Update product
    API->>Redis: Invalidate cache
    API-->>Client: OK

10. Key Takeaways

✅ Use versioned keys → avoid stale cache after model changes.
✅ Always set TTL → prevent unbounded memory growth.
Invalidate cache after updates → fresh data on next GET.
✅ Use AddSingleton for IConnectionMultiplexer.
✅ EF Core uses conventions → no [Key] needed unless overriding.