Caching Patterns — Cache-Aside, Read-Through, and Production Design
Common caching design patterns: cache-aside, read-through, write-through, write-behind, and how to choose the right pattern for different data types in a production ASP.NET Core system.
The Four Caching Patterns
Pattern Cache reads Cache writes Who populates cache
──────────────────────────────────────────────────────────────
Cache-Aside App checks App writes Application code
Read-Through Cache checks App writes Cache (via backing store)
Write-Through App checks Cache writes Cache + DB together
Write-Behind App checks Cache first Cache async to DBCache-Aside (Most Common in .NET)
The application checks the cache, loads from the DB on miss, and populates the cache. The cache is transparent to callers.
// IPatientQueryService.cs
public interface IPatientQueryService
{
Task<PatientDto?> GetByIdAsync(Guid id, CancellationToken ct);
}
// Infrastructure/PatientQueryService.cs
public sealed class PatientQueryService : IPatientQueryService
{
private readonly HybridCache _cache;
private readonly PatientRepository _repo;
public async Task<PatientDto?> GetByIdAsync(Guid id, CancellationToken ct)
{
return await _cache.GetOrCreateAsync(
key: $"patient:{id}",
factory: async ct =>
{
var patient = await _repo.GetByIdAsync(id, ct);
return patient?.ToDto();
},
options: new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(30),
LocalCacheExpiration = TimeSpan.FromMinutes(5),
Tags = [$"patient:{id}"]
},
cancellationToken: ct);
}
}Cache-aside characteristics:
- Cache failure falls through to DB — resilient
- Cache is empty on cold start, warms with use
- Works with any cache implementation
- Most common pattern for web APIs
Read-Through
The cache acts as the primary data source. On a miss, the cache itself fetches from the backing store. Common in dedicated caching solutions (Redis with Redis modules, NCache).
// Not common in basic .NET caching — usually library-driven
// Conceptually: caller only talks to cache, never DB directly
// Simulated read-through: cache wrapper that auto-fetches
public sealed class ReadThroughCache<TKey, TValue>
{
private readonly IDistributedCache _cache;
private readonly Func<TKey, Task<TValue?>> _loader;
public async Task<TValue?> GetAsync(TKey key, CancellationToken ct)
{
var cacheKey = key!.ToString()!;
var bytes = await _cache.GetAsync(cacheKey, ct);
if (bytes is not null)
return JsonSerializer.Deserialize<TValue>(bytes);
// Cache is responsible for loading
var value = await _loader(key);
if (value is not null)
await _cache.SetAsync(cacheKey,
JsonSerializer.SerializeToUtf8Bytes(value),
new DistributedCacheEntryOptions
{ AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }, ct);
return value;
}
}Write-Through
Cache and DB are updated together. Reads always hit the cache (which is always fresh).
// Write-through for drug formulary (always keep cache current)
public sealed class FormularyService
{
private readonly HybridCache _cache;
private readonly DrugRepository _repo;
private readonly IUnitOfWork _uow;
public async Task<Result> UpdateDrugAsync(
Guid drugId, UpdateDrugDto dto, CancellationToken ct)
{
var drug = await _repo.GetByIdAsync(drugId, ct);
if (drug is null) return Result.Failure(DrugErrors.NotFound);
drug.UpdateDetails(dto.Name, dto.Form, dto.DoseRange);
await _uow.SaveChangesAsync(ct);
// Write-through: update cache immediately (not invalidate)
await _cache.SetAsync(
$"drug:{drugId}",
drug.ToDto(),
new HybridCacheEntryOptions { Expiration = TimeSpan.FromHours(4) },
ct);
return Result.Success();
}
}Write-through advantage: cache always has the latest — no cold start per entry. Write-through risk: if DB write succeeds but cache write fails, they are inconsistent.
Cache-Aside vs Write-Through Decision
Cache-Aside:
✓ Most flexible
✓ Cache can fail independently — app degrades gracefully to DB
✗ Cold cache after restart — every entry warmed on first miss
Use when: general-purpose caching, most scenarios
Write-Through:
✓ Cache is always populated — no cold start per entity
✓ Read performance is consistent from day one
✗ Every write requires two operations (DB + cache)
✗ Cache-DB consistency harder to guarantee atomically
Use when: read-heavy, write-infrequent reference data (formulary, ICD codes)Caching Layer in Clean Architecture
Domain: no caching — domain is pure logic
Application: use IPatientCache interface — does not know about Redis
Infrastructure: PatientCache : IPatientCache — uses HybridCache/Redis
API: calls application handlers — not aware of cache
// Application/Abstractions/IPatientCache.cs
public interface IPatientCache
{
Task GetByIdAsync(Guid id, CancellationToken ct);
Task InvalidateAsync(Guid id, CancellationToken ct);
Task InvalidateAllAsync(CancellationToken ct);
}
// Infrastructure/Caching/PatientCache.cs
public sealed class PatientCache : IPatientCache { /* HybridCache impl */ } Per-Data-Type Caching Strategy
Drug formulary (changes monthly, high read volume):
Pattern: Write-through + tag invalidation
L1 TTL: 30 minutes
L2 TTL: 4 hours
Invalidation: on formulary update by pharmacy admin
Patient demographics (changes occasionally, high read volume):
Pattern: Cache-aside with write-invalidation
L1 TTL: 5 minutes
L2 TTL: 30 minutes
Invalidation: on patient profile update
Patient allergies (safety-critical):
Pattern: Do not cache — always load fresh from DB
INR readings (monitoring, real-time):
Pattern: Short TTL only — 60 seconds, never longer
Invalidation: on every new reading recorded
Reference data (ICD codes, ward list):
Pattern: Aggressive cache — changes very rarely
L1 TTL: 1 hour
L2 TTL: 24 hours
Invalidation: on admin update actionProduction issue I've seen: A team applied a uniform 30-minute TTL to everything. Patient allergy updates were invisible for up to 30 minutes. A nurse administered penicillin to a patient who had just had their penicillin allergy added during the 30-minute window. Tiered caching with no-cache for safety-critical data is not optional in clinical systems.
Graceful Degradation When Cache Fails
public async Task<PatientDto?> GetPatientAsync(Guid id, CancellationToken ct)
{
try
{
return await _cache.GetByIdAsync(id, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Cache unavailable, falling back to database");
var patient = await _repo.GetByIdAsync(id, ct);
return patient?.ToDto(); // DB is the source of truth
}
}The cache is an optimization, not a dependency. The application must function when the cache is unavailable.
Key Takeaway
Cache-aside is the default pattern: check cache, miss, load from DB, populate. Use write-through for data you want always-warm in cache. Never cache safety-critical data that must be fresh on every read. Design your caching layer behind an interface in the Application layer — Infrastructure provides the Redis implementation. Caching is an optimization; the system must degrade gracefully when the cache is unavailable.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.