In-Memory Caching

A lightweight, thread-safe cache for data that's expensive to compute but fine to serve slightly stale โ€” leaderboard rankings, aggregate stats, anything read far more often than it changes. No Redis, no distributed cache, just a locked dictionary with a TTL.

The shape

Key pieces: a private cache entry type carrying a timestamp, a lock object guarding all access, and a TTL check on read. Writes that should invalidate the cache call InvalidateCache explicitly rather than relying on expiry alone.

LeaderboardService.cs
public class LeaderboardService
{
    private readonly Dictionary<Guid, LeaderboardCache> _cache = [];
    private readonly object _lock = new();

    private class LeaderboardCache
    {
        public DateTime CachedAt { get; set; }
        public List<LeaderboardEntry> Entries { get; set; } = [];
    }

    public List<LeaderboardEntry> GetCategoryLeaderboard(
        Guid categoryId, IEnumerable<Rating> ratings, int cacheDurationMinutes = 5)
    {
        lock (_lock)
        {
            if (_cache.TryGetValue(categoryId, out var cached) &&
                DateTime.UtcNow - cached.CachedAt < TimeSpan.FromMinutes(cacheDurationMinutes))
            {
                return cached.Entries;
            }

            var entries = BuildEntries(ratings);
            _cache[categoryId] = new LeaderboardCache { CachedAt = DateTime.UtcNow, Entries = entries };
            return entries;
        }
    }

    public void InvalidateCache(Guid categoryId)
    {
        lock (_lock)
        {
            _cache.Remove(categoryId);
        }
    }

    private static List<LeaderboardEntry> BuildEntries(IEnumerable<Rating> ratings)
        => ratings
            .OrderByDescending(r => r.CurrentRating)
            .Select((r, i) => new LeaderboardEntry { UserId = r.UserId, Rank = i + 1 })
            .ToList();
}

Invalidate on write

Whatever mutates the underlying data is responsible for clearing the relevant cache entry. This keeps the caching concern out of the read path's business logic โ€” the write-side service just calls one method after it's done.

usage
public async Task RecordMatchAsync(RecordMatchRequest request)
{
    // ... update ratings, save match ...

    _leaderboardService.InvalidateCache(request.CategoryId);
}

When this is the right tool

  • Single-instance deployment (a locked in-process dictionary doesn't sync across servers โ€” for multi-instance, reach for a distributed cache instead).
  • Read-heavy, write-light data where a few minutes of staleness is acceptable.
  • You want zero extra infrastructure (no Redis connection string, no extra moving part to monitor).