Field notes

One attribute caches any function. Naming the staleness window — and putting the hit rate on a dashboard — is the actual engineering.

From September 2015 to July 2016 I wrote two aspects at Serve, a payments company American Express had acquired. The first timed every function it was attached to and threaded a request ID through the call stack. The second cached the result of any function I put it on, and it was the same shape: an attribute, one line, on any method.

“So you can just go into any function — to any function that you want to be able to cache requests and responses from — and just put an AOP flag on it, and based on the request input, it would hash that… it would check inside of Redis to see whether or not that hash was already there. If it was there, then it would go pull the value out of Redis and just simply deserialize and return.” The reach is what made it worth building: “we could cache at any tier, at any place, throughout our API stack. And all it was was a single line of code.”

It went on business-rules APIs and on get-user-info, a call that ran hundreds of millions of times a day. “We reduced the load on our databases significantly instead of needing to hit the database that many times.”

The staleness dial

engraving of a stone-and-timber ice house with its front wall cut away, four courses of large ice blocks packed under thick straw thatch, a short ladder leaning against the stack, a plank door to the right

There was no invalidation. Not a light version of it — none. “That was the trick with this, is that we didn’t have any invalidation.” An entry lived about a minute, then it was gone, and the next read refilled it. One minute “was acceptable to the business, for certain things.”

The risk got named out loud before it shipped, and on identity data it was a real one: “What happens if you deactivated a user within that 1 minute, or what happens if a user made a change to some of their info that maybe wouldn’t show up on the website for 1 minute? But that was a risk that the business users were willing to take.”

That last sentence is the operating rule I still run. The business owns the staleness dial. My job is to name the window and price it — this call is up to a minute behind, here’s what that costs on the day someone deactivates an account — not to pick the number quietly and hope nobody notices what it bought.

cached value — one-minute TTL, no invalidation stale reads until the entry expires read miss → fill hash of inputs → Redis user deactivated write lands in the database entry expires next read refills time
Fig. 01 — With no invalidation, a write is invisible until the entry expires — and the length of that window is a business decision.

Fig. 01 · pinch or scroll to zoom · drag to pan

Measuring the cache

A cache nobody measures is a guess. Because the caching aspect sat beside the tracing one, every cached function reported: “not only were we able to cache, but I was keeping track of cache hits all within Grafana, and cache misses, and latencies, and all that kind of thing, so that we could see whether or not a cache was effective.”

That’s the whole test. Not that the cache exists — that its hit rate sits on a chart, next to the latency of the thing it stands in front of. A cache with a bad hit rate is a dependency you added for nothing, and from the code it looks exactly like a good one.

This is also where the habit started, on my own account: “one of my first times where I really was able to take monitoring and understand that monitor everything is important.” I still argue the same thing about tests that run in production.

One line, today

Which library it was, I hedge: “I believe it was something that was PostSharp. I believe that was right.” PostSharp still documents that aspect — CacheAttribute, “when applied on a method, causes the return value of the method to be cached for the specific list of arguments passed to this method call,” with AbsoluteExpiration and SlidingExpiration given in minutes. The same vendor’s current framework carries it forward as Metalama Caching, whose [Cache] aspect caches “the return value of a method as a function of its arguments with just a custom attribute.”

None of the code below is from that build. It’s reference, written from those docs and Microsoft’s, and I haven’t compiled it.

Packages: Metalama.Patterns.Caching.Aspects for the attribute, Metalama.Patterns.Caching.Backends.Redis for the backend. Profiles carry the TTL, so the number lives in one place instead of on every method.

// Registration: a default profile at a one-minute TTL, Redis as the backend.
builder.Services.AddMetalamaCaching(caching => caching
    .AddProfile(new CachingProfile { AbsoluteExpiration = TimeSpan.FromMinutes(1) })
    .WithBackend(backend => backend.Redis()));

// The cached method: the attribute is the entire change.
public class UserDirectory(IUserRepository repository)
{
    [Cache]
    public UserInfo GetUserInfo(string userId, string channel)
        => repository.Load(userId, channel);
}

Two details there are documented rather than obvious. Profiles are “sets of options that can be modified at run time,” so the staleness window is a runtime dial and not a redeploy — which is what you want on a number the business owns. And the Redis backend will run a local in-memory L1 in front of Redis, kept honest across nodes by Redis notifications: “When you run several nodes of your applications with the same Redis server and the same KeyPrefix, the L1 caches of each application node are synchronized using Redis notifications.”

Hash the inputs

The hand-rolled version is worth writing out, because it’s where the telemetry goes. .NET’s HybridCache is the in-process-plus-distributed shape — Microsoft.Extensions.Caching.Hybrid, with a Redis IDistributedCache as the secondary tier — and its GetOrCreateAsync calls the factory only on a miss. So the counter that belongs inside the factory is the miss counter, and hits are arithmetic.

using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Caching.Hybrid;

public sealed class MeasuredCache
{
    private readonly HybridCache cache;
    private readonly Counter<long> calls;
    private readonly Counter<long> fills;
    private readonly Histogram<double> fillDuration;

    public MeasuredCache(HybridCache cache, IMeterFactory meterFactory)
    {
        this.cache = cache;
        var meter = meterFactory.Create("Example.Cache");
        this.calls = meter.CreateCounter<long>("example.cache.calls", unit: "{call}");
        this.fills = meter.CreateCounter<long>("example.cache.fills", unit: "{fill}");
        this.fillDuration = meter.CreateHistogram<double>(
            "example.cache.fill.duration", unit: "s");
    }

    public async ValueTask<T> GetOrFillAsync<T>(
        string function,
        object inputs,
        Func<CancellationToken, ValueTask<T>> load,
        CancellationToken cancellationToken = default)
    {
        var tag = new KeyValuePair<string, object?>("cache.function", function);
        this.calls.Add(1, tag);

        return await this.cache.GetOrCreateAsync(
            $"{function}:{HashOf(inputs)}",
            async token =>
            {
                this.fills.Add(1, tag);
                var started = Stopwatch.GetTimestamp();
                try
                {
                    return await load(token);
                }
                finally
                {
                    this.fillDuration.Record(
                        Stopwatch.GetElapsedTime(started).TotalSeconds, tag);
                }
            },
            new HybridCacheEntryOptions
            {
                Expiration = TimeSpan.FromMinutes(1),
                LocalCacheExpiration = TimeSpan.FromMinutes(1),
            },
            cancellationToken: cancellationToken);
    }

    private static string HashOf(object inputs) =>
        Convert.ToHexString(
            SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(inputs)));
}

Meter, CreateCounter, CreateHistogram, and tagged Add calls are the documented .NET metrics API, which is what an OpenTelemetry exporter reads. Hit rate is 1 - fills / calls, per function, off the cache.function tag.

Two honest notes on that arithmetic. First, the factory counter counts fills, not misses: a HybridCache instance “ensures that only one concurrent caller for a given key calls the factory method, and all other callers using the same instance wait for the result of that call,” so a burst of simultaneous misses shows up as one fill and a pile of hits. That’s stampede protection doing its job, and it flatters the hit rate a little. Second, the hash isn’t only shortening the key. The docs give both reasons plainly: “Avoid using external user input directly in cache keys,” and the default implementation “restricts keys to 1024 characters by default,” with longer keys bypassing the cache entirely. A fixed-width hash of the inputs answers both — the same job hashing the request did in 2015.

The three-way trade

TTL only Event invalidation Both
What it is The business names one number and lives with it. The engineer owns exporting the hit rate, and owns being correct while the value is behind. The business gets “immediately” for a class of data. The engineer owns every write path knowing every read that cached it — PostSharp’s own docs are blunt about that coupling: update methods “need to have a precise knowledge of cached methods.” HybridCache ships this as tags — invalidation as the fast path, TTL as the backstop.
What breaks A write is invisible for the length of the window. The failure is bounded, and it’s the same size every time. One write path that forgets one key leaves it stale until eviction. Unbounded, and invisible. Two mechanisms to keep honest, plus a scope to track — “invalidated in the current server and the secondary out-of-process storage. However, the in-memory cache in other servers isn’t affected.”

When invalidation is the risk

TTL-only is not the timid option. It is the one whose worst day you can describe in a sentence. Invalidation’s worst day is harder to bound, and it fails in two directions at once.

Wrong invalidations are a correctness problem that reads to a user as data loss. Meta, running caches at a scale where this is a full-time engineering discipline, says it flat: “In some cases, cache inconsistencies are almost as bad as data loss on a database. From the user’s perspective, it can even be indistinguishable from data loss” (Cache made consistent).

Too many invalidations are a load problem. Invalidate a hot key over and over and every reader falls through to the expensive path at once — Facebook’s Scaling Memcache at Facebook (USENIX NSDI ‘13) calls that a thundering herd and answers it with leases, and Optimal Probabilistic Cache Stampede Prevention (VLDB 2015) calls the same failure a cache stampede and answers it with probabilistic early expiration. I’ve watched the far end of that curve since — invalidation running hot enough that a cache stops being a cache — and it’s a worse afternoon than a stale profile edit.

The honest limit on my own build sits somewhere else, and no invalidation policy fixes it. Google’s SRE book warns that “a service using a capacity cache cannot sustain its expected load under an empty cache” (Addressing cascading failures). A cache that reduces database load significantly is exactly that kind of cache. Cold it and the load goes straight back where it came from. That’s the question I’d ask today and didn’t ask then: not what happens if this is stale, but what happens in the first minute after it’s empty.

What I’d do again

Two things, unchanged. Make the caching decision one line at the call site, so it costs nothing to add and nothing to take back out — a former teammate told me afterward it “saved him several times where he just needed to be able to cache a single place and to monitor it.” And ship the hit rate with it, on the same dashboards as the latency, so the argument about whether it helped gets settled by a chart.

I don’t pick staleness windows. I name them, price them, and put the number where the business can move it. That team took a minute of staleness on identity data at hundreds of millions of calls a day, with their eyes open. That is the right way for that decision to get made, and it is the only part of this that doesn’t come out of a library.

Underneath

Docs checked 2026-09-02. PostSharp’s CacheAttribute, caching guide, and invalidation page all still stand, and the same vendor’s Metalama caching docs carry the aspect forward across its profile and Redis pages — doc.metalama.net now redirects into doc.postsharp.net/metalama. On the Microsoft side: HybridCache, in-memory caching, and metrics. The TTL itself is the oldest primitive here — Redis’s own keys and expiration page sets it with EXPIRE or SET … EX, and “when the time to live elapses, the key is automatically destroyed.”

Related system

More field notes

Start

Tell me what’s stuck

I’ll tell you in about a day whether I’m the right person. The first conversation is fit, not a free architecture review.