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:
- Distributed cache
- Session store
- Message broker (Pub/Sub, Streams)
- Real-time leaderboard and analytics storage
Key benefits:
- Blazing fast (in-memory)
- Supports advanced data types (strings, hashes, lists, sets, sorted sets, streams, etc.)
- Optional persistence via snapshots (RDB) or logs (AOF)
- Built-in features like TTL, Pub/Sub, rate limiting, and distributed locks.
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));
- Old keys like
v1:user:1
are ignored. - They expire automatically if you set a TTL.
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));
- After 10 minutes, the key expires.
- Ensures fresh data and memory efficiency.
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:
- First GET → MISS → fetches from DB → re-caches.
- Second GET → HIT → serves from Redis.
- 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
ConnectionMultiplexer
is thread-safe.- Designed to be shared and reused.
- Creating new instances per request (
Scoped
/Transient
) → slow & wasteful. - Official docs recommend single instance per app.
builder.Services.AddSingleton<IConnectionMultiplexer>(
_ => ConnectionMultiplexer.Connect("localhost:6379"));
8. EF Core & Primary Keys
- EF Core uses conventions:
- Property named
Id
or<EntityName>Id
→ automatically PK.
- Property named
- You don't need
[Key]
unless you want explicit control. - Optionally, you can configure manually:
csharp modelBuilder.Entity<Product>().HasKey(p => p.Id);
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.