Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
CachingCache Patterns.NETRedisArchitecture
Share:𝕏

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 DB

Cache-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.

C#
// 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).

C#
// 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).

C#
// 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 action

Production 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

C#
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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.