Skip to main content

Command Palette

Search for a command to run...

The Cache Family

One `ICacheService` contract, two swappable backends — an in-memory LRU cache (BitFaster) and a disk-backed cache that survives process restarts — switchable through configuration, safe-off by default.

Updated
6 min readView as Markdown
The Cache Family

Problem

.NET already gives you IMemoryCache and IDistributedCache. Both are well-designed for what they are. Neither is an application-level abstraction — they're platform abstractions, and your services end up depending on one of them directly. Swap from in-memory to Redis and you refactor services. Disable caching to debug something and you need mocks. Write a test and you need a test double for whichever one you picked. One of the recurring problems that pushed me toward PowerCSharp in the first place was a caching layer so tightly coupled to Redis that the test suite needed a Docker container just to run.

Why the obvious solutions fail

Depending on IMemoryCache directly works fine until you need to swap backends or disable caching entirely for a given environment — at that point every consumer that took a direct dependency has to change. Wrapping it in your own interface, ad hoc, per project, solves it once and then you're maintaining a bespoke abstraction in every codebase — which is the same "scattered and inconsistent" problem the rest of PowerCSharp exists to avoid.

The design decision

ICacheService is that abstraction, done once: TryGet, Set, Remove, Clear, async equivalents, GetOrCreate/GetOrCreateAsync with GetOrAdd-style atomicity to avoid cache stampedes, and GetMetadata. Consumers depend on the interface. Which implementation is active is a runtime decision, not a compile-time one — the same two-layer gating as every other pluggable feature: no package reference means the type isn't even in the dependency graph; a package referenced with the flag off gets NoOpCacheService, registered via TryAddSingleton; a package referenced with the flag on gets the real provider's plain AddSingleton, which overrides the NoOp floor.

One specific decision worth explaining on its own: CacheService.GetAsync<T> returns CacheResult<T>, not T?. Returning a nullable T is ambiguous — was it a miss, or a hit on a cached null? CacheResult<T> makes that distinction a type, not a convention: Hit, Value, Metadata, with Miss() and Found(value, metadata) as the two ways to construct one. A miss is a miss. A hit with a null value is a different, legitimate case. No boolean flag riding alongside the value to disambiguate them after the fact.

Implementation

public class MyService(ICacheService cache)
{
    public async Task<MyData> GetDataAsync(string key)
    {
        var result = await cache.GetAsync<MyData>(key);
        if (result.Hit)
            return result.Value!;

        var data = await FetchFromSourceAsync(key);
        await cache.SetAsync(key, data, TimeSpan.FromMinutes(10));
        return data;
    }
}
{
  "PowerFeatures": {
    "Cache": { "Enabled": true, "Provider": "BitFaster", "Capacity": 5000, "DefaultTtl": "00:05:00" }
  }
}

Switching Provider from BitFaster to Disk is a configuration change, not a code change — for the consuming service. That's true once both provider packages are already referenced in the project; adding a provider that isn't referenced yet is still a real dependency decision, not a free toggle.

BitFaster: the in-memory provider

Backed by BitFaster.Caching's ConcurrentLru (a W-TinyLFU variant), lock-free and fully thread-safe. Cache-stampede protection comes from GetOrAdd atomicity in GetOrCreate/GetOrCreateAsync — two concurrent callers asking for the same missing key get one factory invocation, not two. This is the provider for the common case: fast, in-process, gone when the process restarts.

Disk: the provider that survives a restart

The Disk provider exists because of a specific, real problem: an API server computing an expensive dependency graph on every startup, at a 45-second cost — 45 seconds of outage on every production deploy, 45 seconds of waiting after every local code change. In-memory caching doesn't help across a restart. The dataset was also too large for RAM in some environments, and multiple processes on the same host needed to share cached state. Three requirements no in-memory cache can meet by definition.

The obvious implementations are each wrong in a specific way. Writing straight to the target file risks a half-written file if the process crashes mid-write — operating systems don't guarantee atomic writes for arbitrary sizes. A small embedded database (SQLite, LiteDB) adds a connection model and a query engine to a capability that only needs put/get/evict — real overhead for a job that doesn't need it. Memory-mapped files are portable in theory and genuinely hard to manage correctly across process boundaries with different lifetimes in practice.

What shipped instead: write-to-temp-then-atomic-rename, so a reader never sees a partial file; per-key .lock files (FileStream with FileShare.None) instead of one global lock, so N concurrent accessors don't serialize into O(N) queuing; and metadata split into sidecar .meta files instead of embedded in the .dat file header. That last one looked like over-engineering until background cleanup needed to check expiry across 100,000 entries — deserializing every value to check a timestamp would mean loading potentially gigabytes into memory just to decide what to evict. Scanning 100,000 small .meta files instead is fast and touches none of the cached data itself. The decision that looked like unnecessary complexity turned out to be the difference between a cleanup pass that's cheap and one that falls over under load.

<CacheDirectory>/
  <key-hash>.dat     — the serialized value
  <key-hash>.meta    — CacheMetadata, checked without touching .dat
  <key-hash>.lock    — transient, per-key, cross-process

Trade-offs

Every disk write produces two files, and every hit updates the .meta file — real write amplification on write-heavy workloads. There's no atomic multi-key operation: evicting one entry and writing another aren't a single transaction. File locking is the coordination mechanism, which means this provider is designed for local file systems only — network file systems like NFS or SMB don't give reliable locking semantics, and using the Disk provider there is a known limitation, not an unlisted risk. Eviction in this version is count-and-TTL only, not size-aware — a deliberate v1 scope decision, with size-based eviction left for later rather than shipped half-finished now.

Production considerations

If a process crashes between writing the temp file and the atomic rename, the .tmp file is orphaned; background cleanup removes stale temp files, and the real .dat file either exists complete or doesn't exist at all — there's no partial-write state a reader can observe. If the disk is full, WriteAllBytesAsync throws IOException, which the cache catches, logs, and turns into a silent miss on that write — the request that triggered it keeps working, just without getting cached, and the next request tries again.

Conclusion

The two providers aren't "one is better" — they're for different constraints. BitFaster when you want speed and don't need survival across a restart. Disk when the dataset doesn't fit in RAM, needs to survive a restart, or needs to be shared across processes on one host, and you can afford the I/O cost that comes with it. Which constraint have you actually hit — restart survival, memory pressure, or cross-process sharing — and did it change which trade-off felt acceptable?

PowerCSharp Technical Blog

Part 1 of 7

PowerCSharp is a comprehensive library of extension methods, utilities, and helper classes designed to enhance your C# development experience. Built by a senior C# architect with 20+ years of experience, this library provides practical, well-tested solutions for common programming challenges.

Up next

The Features Framework

A feature flag library answers one question: is this feature enabled? It doesn't answer the question that actually matters in a real application: what happens to dependency injection when the feature is *not* enabled? That gap is where `PowerCSharp.Features` came from.