Caching in .NET, done properly: strategies, pitfalls, and Clean Architecture
A practical guide to caching in .NET — cache-aside, in-memory vs Redis, HybridCache, invalidation, stampedes, and keeping it all out of your domain layer.
Caching is the easiest performance win in most .NET applications. It is also the source of the weirdest bugs you’ll ever debug. Stale data that only some users see. A dashboard that’s fast all day and falls over at 9:00 AM sharp. An IMemoryCache call buried in a handler that nobody remembers writing.
I’ve hit all of these. This post is the guide I wish I’d had: which caching strategy to use when, how to implement them cleanly in .NET, and how to keep caching from leaking all over your architecture. The patterns here come from my Caching-in-Clean-Architecture-DotNet repo and from running Redis-backed systems in production.
First: should you even cache this?
Caching earns its keep when data is read far more often than it changes and slightly stale is acceptable. A product catalog, a permissions list, reference data, an expensive report: all good candidates. A bank balance right before a withdrawal: absolutely not.
Before reaching for a cache, ask three questions:
- How expensive is the read? If the query takes 5ms, caching it adds complexity for nothing. Measure first.
- How stale can it be? The answer defines your TTL. If the answer is “not at all”, you need invalidation, not just expiry, and that is where most of the real work lives.
- What happens when the cache is empty? Cold starts and evictions will happen. If a cache miss takes down your database, the cache was hiding a problem, not solving it.
The workhorse: cache-aside
Most application caching is the cache-aside pattern: check the cache, miss, load from the source, store, return.
public async Task<ProductDto?> GetProductAsync(int id, CancellationToken ct)
{
var key = $"product:{id}";
var cached = await _cache.GetAsync<ProductDto>(key, ct);
if (cached is not null)
return cached;
var product = await _repository.GetByIdAsync(id, ct);
if (product is null)
return null;
var dto = product.ToDto();
await _cache.SetAsync(key, dto, TimeSpan.FromMinutes(5), ct);
return dto;
}
Simple and effective. It also has a failure mode that the next two sections deal with: what happens when 500 requests miss at the same instant?
In-memory vs distributed: pick deliberately
IMemoryCache lives inside your process. It’s essentially a fancy dictionary: a read is a lookup, with no serialization and no network. The catch is that every instance of your app has its own cache. Run three replicas behind a load balancer and you have three caches that disagree with each other, and every deployment starts cold.
IDistributedCache (Redis) lives outside your process. All instances share one view, data survives restarts, and you can cache far more than fits in app memory. The cost is a network hop of roughly a millisecond, plus serialization, on every read.
The rule of thumb I use:
| Scenario | Choice |
|---|---|
| Single instance, small hot data | IMemoryCache |
| Multiple instances, shared state | Redis |
| Session/auth-adjacent data | Redis (must survive restarts) |
| Tiny static reference data (country codes, enums) | IMemoryCache, long TTL |
On Azure, “Redis” means Azure Cache for Redis. Put it in the same region as your app. Cross-region cache calls can be slower than the database query you’re trying to avoid, which defeats the entire point.
HybridCache: the new default (.NET 9+)
.NET 9 shipped HybridCache, and if you’re starting fresh it should be your first choice. It combines both levels, an in-process L1 for speed and a distributed L2 for consistency, and fixes cache-aside’s sharp edges for free:
public async Task<ProductDto?> GetProductAsync(int id, CancellationToken ct)
{
return await _hybridCache.GetOrCreateAsync(
$"product:{id}",
async token => (await _repository.GetByIdAsync(id, token))?.ToDto(),
new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5),
LocalCacheExpiration = TimeSpan.FromMinutes(1)
},
cancellationToken: ct);
}
Two things you get without writing any code:
- Stampede protection. If 500 requests miss the same key simultaneously, only one executes the factory; the rest wait for its result. With plain cache-aside, all 500 hit your database at once. That is the “falls over at 9:00 AM” bug: the morning rush arrives just after a TTL expires.
- Two-level reads. Hot keys are served from local memory (no network hop, no deserialization), while Redis keeps the instances consistent.
Note the shorter LocalCacheExpiration: the L1 copy can’t be remotely invalidated on its own, so you keep it brief and let L2 be the authority.
Where caching belongs in Clean Architecture
Caching is a cross-cutting concern, and cross-cutting concerns love to sprawl. Sprinkle cache calls through your handlers and six months later nobody knows which code path is authoritative. The fix is boring and effective, the same as for any infrastructure concern:
The domain layer must not know caching exists. The application layer may know that caching can exist. Only infrastructure knows how.
Domain
Knows about caching: nothing
- Entities, value objects
- Raises
ProductUpdatedEvent
Application
Knows about caching: that it can exist
ICacheServiceinterfaceICachedQuerymarkerCachingBehaviourpipeline- Event handler evicts keys
Infrastructure
Knows about caching: how
RedisCacheServiceIDistributedCache/HybridCache- Azure Cache for Redis
The application layer owns an interface that describes intent, not Redis:
// Application/Common/Interfaces/ICacheService.cs
public interface ICacheService
{
Task<T?> GetAsync<T>(string key, CancellationToken ct = default);
Task SetAsync<T>(string key, T value, TimeSpan? expiry = null,
CancellationToken ct = default);
Task RemoveAsync(string key, CancellationToken ct = default);
}
Infrastructure implements it, so you can swap in-memory for Redis without touching a single handler:
// Infrastructure/Caching/RedisCacheService.cs
public sealed class RedisCacheService(IDistributedCache cache) : ICacheService
{
public async Task<T?> GetAsync<T>(string key, CancellationToken ct = default)
{
var bytes = await cache.GetAsync(key, ct);
return bytes is null ? default : JsonSerializer.Deserialize<T>(bytes);
}
public async Task SetAsync<T>(string key, T value, TimeSpan? expiry = null,
CancellationToken ct = default)
{
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = expiry ?? TimeSpan.FromMinutes(5)
};
await cache.SetAsync(key,
JsonSerializer.SerializeToUtf8Bytes(value), options, ct);
}
public Task RemoveAsync(string key, CancellationToken ct = default)
=> cache.RemoveAsync(key, ct);
}
Then, instead of hand-writing the cache check in every handler, do it once as a MediatR pipeline behavior. Queries opt in with a marker interface; handlers stay oblivious:
public interface ICachedQuery
{
string CacheKey { get; }
TimeSpan? Expiry => null;
}
public sealed class CachingBehaviour<TRequest, TResponse>(ICacheService cache)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICachedQuery
{
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
var cached = await cache.GetAsync<TResponse>(request.CacheKey, ct);
if (cached is not null) return cached;
var response = await next();
await cache.SetAsync(request.CacheKey, response, request.Expiry, ct);
return response;
}
}
Adding caching to a query is now one line (implement ICachedQuery, define the key), and removing it is just as easy. That reversibility is worth more than it looks: caching decisions change as traffic changes.
Invalidation: the actual hard part
Expiry (TTL) handles “eventually correct”. Invalidation handles “correct after a write”. Most systems need both. Here is what has worked for me:
Evict on write, via domain events. When a command changes an entity, publish a domain event; an event handler evicts the affected keys. The command handler itself never mentions the cache, the same separation as before.
public sealed class ProductUpdatedEventHandler(ICacheService cache)
: INotificationHandler<ProductUpdatedEvent>
{
public Task Handle(ProductUpdatedEvent e, CancellationToken ct)
=> cache.RemoveAsync($"product:{e.ProductId}", ct);
}
Short TTLs are a feature, not a compromise. A 60-second TTL on a hot endpoint often removes the need for precise invalidation entirely. Ask the product owner “is 60-second-old data acceptable here?” The answer is yes far more often than engineers assume.
Version the key when eviction gets complicated. If one entity appears in dozens of cached lists, don’t chase them all down. Put a version in the key (products:v42:list:page2), bump the version on write, and let the old entries age out on their own.
Pitfalls I’ve actually hit
Caching null accidentally, or refusing to. Decide explicitly. If “product 42 doesn’t exist” is expensive to compute, cache a sentinel for it with a short TTL. Otherwise a scraper hammering non-existent IDs bypasses your cache entirely and hits the database every time.
Unbounded collections. Never cache “all products”. Cache the result of a specific query with its parameters in the key. Otherwise memory grows until eviction kicks in, and then your hit rate craters unpredictably.
Serialization surprises. Redis stores bytes, not objects. That type you cached with a DateTimeOffset, a polymorphic field, and a lazy-loaded EF navigation property? It won’t round-trip the way you think. Cache flat DTOs, never entities.
The thundering herd. Covered above: plain cache-aside offers no stampede protection. If you can’t use HybridCache, at least jitter your TTLs (5min + Random(0-30s)) so a whole class of keys doesn’t expire in the same second.
Treating the cache as required. Redis being down should mean a slow app, not a dead one. Wrap cache reads so failures degrade to the source instead of throwing, and load-test that path, because “database suddenly gets 100% of traffic” is its own event.
A reasonable default setup
If you want a starting point that will survive most .NET workloads:
HybridCache(or cache-aside via a pipeline behavior on .NET 8) with Azure Cache for Redis as L2, same region as the app- TTL of 1–5 minutes on most keys, with jitter; longer only for data that provably never changes
- Evict on write through domain events for anything users edit directly
- Key convention like
{entity}:{id}and{entity}:{version}:{query}:{params}: boring and greppable - Cache interface in the application layer, implementation in infrastructure, so the day you switch providers it’s one file
Measure the hit rate once it’s live (Redis’s INFO stats, or App Insights custom metrics). A cache below ~80% hits on a read-heavy path usually means the keys are too specific or the TTL is too short. And a cache you never measure is just a rumor that your app is fast.