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
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.
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
I’ll tell you in about a day whether I’m the right person. The first conversation is fit, not a free architecture review.