# 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

```csharp
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;
    }
}
```

```json
{
  "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.

```plaintext
<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?

![](https://cdn.hashnode.com/uploads/covers/6a2a24d47d19511873ccd20a/00c61136-462d-4d76-aa62-437cff4dab09.png align="center")
