Skip to content
Back to skills

Dotnet Caching

ASecurity

Reviews ASP.NET Core / EF Core caching strategy. Flags IMemoryCache used in horizontally-scaled APIs (cache incoherence), cache-aside code with no stampede guard, missing cache invalidation on writes, missed HybridCache adoption on .NET 9+ (advisory), missing HTTP-level caching (ResponseCache/ETags) on cacheable GET endpoints, and caching mutable tracked EF Core entities instead of DTO snapshots.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 6, 2026
developmentgoapidatabase

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 6, 2026

npx -y skills add AgenticPawan/FullStack-Pilot --skill dotnet-caching --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Dotnet Caching?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Dotnet Caching
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/agenticpawan-dotnet-caching/badge)](https://www.skillsdirectory.com/skills/agenticpawan-dotnet-caching)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: dotnet-caching
description: Reviews ASP.NET Core / EF Core caching strategy. Flags IMemoryCache used in horizontally-scaled APIs (cache incoherence), cache-aside code with no stampede guard, missing cache invalidation on writes, missed HybridCache adoption on .NET 9+ (advisory), missing HTTP-level caching (ResponseCache/ETags) on cacheable GET endpoints, and caching mutable tracked EF Core entities instead of DTO snapshots.
when_to_use: caching strategy, IMemoryCache, IDistributedCache, Redis, cache-aside, cache stampede, GetOrCreateAsync, cache invalidation, stale cache, HybridCache, ResponseCache, ETag, If-None-Match, cache DTO, tracked entity cache, horizontally scaled cache
---

## Standard IDs

| ID | Severity | What it checks |
|----|----------|----------------|
| CH-001 | P1 | `IMemoryCache` used for shared state in a horizontally-scaled/multi-instance API |
| CH-002 | P1 | Cache-aside implemented without a stampede guard around cache population |
| CH-003 | P1 | Cache entry never invalidated/updated on the corresponding write path |
| CH-004 | P3 | Eligible for `HybridCache` (.NET 9+) but still using separate L1/L2 caching (advisory, version-gated) |
| CH-005 | P2 | Cacheable GET endpoint missing `[ResponseCache]`/ETag support |
| CH-006 | P1 | Tracked EF Core entity cached directly instead of a DTO snapshot |

---

## Check A — IMemoryCache in a scaled-out API

### Detection

1. Check `stack-profile.json` / deployment config (Bicep, AKS manifests, App Service scale settings) for more than one instance/replica.
2. If the codebase uses `IMemoryCache` to store data that must be consistent across requests (e.g., feature flags, session-adjacent state, rate-limit counters) and the app is deployed with `instanceCount > 1` or autoscale enabled, flag CH-001.
3. Recommend `IDistributedCache` backed by Redis (`AddStackExchangeRedisCache`) for anything that must be coherent across instances.

### BAD — per-instance cache used for data that must be shared

```csharp
public class PricingService
{
    private readonly IMemoryCache _cache;

    public PricingService(IMemoryCache cache) => _cache = cache;

    public async Task<decimal> GetDiscountAsync(string sku)
    {
        // On a 3-instance deployment, each instance can serve a different
        // discount value for a short window after an update — cache incoherence.
        if (_cache.TryGetValue(sku, out decimal discount))
        {
            return discount;
        }

        discount = await LoadDiscountFromDbAsync(sku);
        _cache.Set(sku, discount, TimeSpan.FromMinutes(10));
        return discount;
    }
}
```

### GOOD — distributed cache shared across all instances

```csharp
public class PricingService
{
    private readonly IDistributedCache _cache;

    public PricingService(IDistributedCache cache) => _cache = cache;

    public async Task<decimal> GetDiscountAsync(string sku)
    {
        var cached = await _cache.GetStringAsync($"discount:{sku}");
        if (cached is not null)
        {
            return decimal.Parse(cached, CultureInfo.InvariantCulture);
        }

        var discount = await LoadDiscountFromDbAsync(sku);
        await _cache.SetStringAsync(
            $"discount:{sku}",
            discount.ToString(CultureInfo.InvariantCulture),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });

        return discount;
    }
}
```

---

## Check B — Cache stampede guard

### Detection

1. Look for cache-aside code that checks-then-populates a cache key (`TryGetValue` → miss → expensive load → `Set`) without any locking or single-flight mechanism.
2. Under concurrent load, a key expiring causes every simultaneous caller to fall through to the expensive load — flag CH-002.
3. Recommend `IMemoryCache.GetOrCreateAsync` (which does not itself de-duplicate concurrent misses) combined with a per-key `SemaphoreSlim`, or a library that supports single-flight semantics.

### BAD — every concurrent miss re-executes the expensive load

```csharp
public async Task<Report> GetReportAsync(int tenantId)
{
    if (_cache.TryGetValue(tenantId, out Report? report))
    {
        return report!;
    }

    // Under load, N concurrent requests on expiry all hit the DB/report engine at once.
    report = await _reportEngine.BuildReportAsync(tenantId);
    _cache.Set(tenantId, report, TimeSpan.FromMinutes(5));
    return report;
}
```

### GOOD — per-key lock prevents a stampede on expiry

```csharp
private static readonly ConcurrentDictionary<int, SemaphoreSlim> _locks = new();

public async Task<Report> GetReportAsync(int tenantId)
{
    if (_cache.TryGetValue(tenantId, out Report? report))
    {
        return report!;
    }

    var gate = _locks.GetOrAdd(tenantId, _ => new SemaphoreSlim(1, 1));
    await gate.WaitAsync();
    try
    {
        // Double-check after acquiring the lock — another caller may have populated it.
        if (_cache.TryGetValue(tenantId, out report))
        {
            return report!;
        }

        report = await _reportEngine.BuildReportAsync(tenantId);
        _cache.Set(tenantId, report, TimeSpan.FromMinutes(5));
        return report;
    }
    finally
    {
        gate.Release();
    }
}
```

---

## Check C — Missing cache invalidation on write

### Detection

1. For every cached read key, search the codebase for the corresponding write/update/delete path (same entity/aggregate).
2. If a write path mutates the underlying data but does not call `_cache.Remove(...)` / update the cache entry for the same key, flag CH-003.
3. Pay special attention to update endpoints that call `SaveChangesAsync()` without any cache interaction at all.

### BAD — price updated in the database but the stale cached value keeps serving

```csharp
public async Task UpdatePriceAsync(int productId, decimal newPrice)
{
    var product = await _db.Products.FindAsync(productId);
    product!.Price = newPrice;
    await _db.SaveChangesAsync();
    // No cache invalidation — GetPriceAsync keeps returning the old cached price
    // until the TTL expires.
}
```

### GOOD — cache entry evicted immediately after the write commits

```csharp
public async Task UpdatePriceAsync(int productId, decimal newPrice)
{
    var product = await _db.Products.FindAsync(productId);
    product!.Price = newPrice;
    await _db.SaveChangesAsync();

    await _cache.RemoveAsync($"product-price:{productId}");
}
```

---

## Check D — HybridCache adoption (advisory, .NET 9+)

### Detection

1. Confirm target framework in the `.csproj` is `net9.0` or later (`<TargetFramework>net9.0</TargetFramework>`).
2. If the codebase manually layers `IMemoryCache` (L1) in front of `IDistributedCache` (L2) with hand-rolled fallback logic, flag CH-004 as an advisory suggestion to adopt `HybridCache`, which provides this pattern plus stampede protection built in.
3. Do not flag on `net6.0`–`net8.0` targets — `HybridCache` ships in `Microsoft.Extensions.Caching.Hybrid` for .NET 9+.

### BAD — hand-rolled two-tier cache with no stampede protection

```csharp
public async Task<Product?> GetProductAsync(int id)
{
    if (_memoryCache.TryGetValue(id, out Product? p))
    {
        return p;
    }

    var cachedJson = await _distributedCache.GetStringAsync($"product:{id}");
    if (cachedJson is not null)
    {
        p = JsonSerializer.Deserialize<Product>(cachedJson);
        _memoryCache.Set(id, p, TimeSpan.FromSeconds(30));
        return p;
    }

    p = await _db.Products.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
    await _distributedCache.SetStringAsync($"product:{id}", JsonSerializer.Serialize(p));
    _memoryCache.Set(id, p, TimeSpan.FromSeconds(30));
    return p;
}
```

### GOOD — HybridCache handles L1/L2 and stampede protection

```csharp
builder.Services.AddHybridCache();

public class ProductService(HybridCache cache, AppDbContext db)
{
    public async Task<Product?> GetProductAsync(int id, CancellationToken ct)
    {
        return await cache.GetOrCreateAsync(
            $"product:{id}",
            async token => await db.Products.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, token),
            new HybridCacheEntryOptions { Expiration = TimeSpan.FromSeconds(30) },
            cancellationToken: ct);
    }
}
```

---

## Check E — HTTP-level caching for cacheable GETs

### Detection

1. Identify GET endpoints returning data that changes infrequently (reference/lookup data, public catalog listings).
2. If neither `[ResponseCache(...)]` nor ETag/`If-None-Match` handling is present, flag CH-005.

### BAD — no HTTP caching hints, every request refetches identical data

```csharp
[HttpGet("categories")]
public async Task<IActionResult> GetCategories()
{
    var categories = await _db.Categories.AsNoTracking().ToListAsync();
    return Ok(categories);
}
```

### GOOD — ResponseCache header lets clients/CDNs avoid re-fetching

```csharp
[HttpGet("categories")]
[ResponseCache(Duration = 300, Location = ResponseCacheLocation.Any)]
public async Task<IActionResult> GetCategories()
{
    var categories = await _db.Categories.AsNoTracking().ToListAsync();
    return Ok(categories);
}
```

---

## Check F — Caching tracked EF Core entities directly

### Detection

1. Search cache-population code for entities loaded via a tracking query (no `AsNoTracking()`) being passed directly into `_cache.Set(...)`.
2. Storing a tracked entity risks stale/incorrect data on subsequent context use and can leak change-tracker state across requests if the entity is later attached to a new context. Recommend mapping to a DTO before caching.

### BAD — tracked entity cached and later reused across requests

```csharp
public async Task<Customer> GetCustomerAsync(int id)
{
    if (_cache.TryGetValue(id, out Customer? cached))
    {
        return cached!; // may be attached to a disposed DbContext's change tracker
    }

    var customer = await _db.Customers.FirstAsync(c => c.Id == id); // tracked
    _cache.Set(id, customer, TimeSpan.FromMinutes(5));
    return customer;
}
```

### GOOD — DTO snapshot is cached, entity never leaves the DbContext scope

```csharp
public record CustomerDto(int Id, string Name, string Email);

public async Task<CustomerDto> GetCustomerAsync(int id)
{
    if (_cache.TryGetValue(id, out CustomerDto? cached))
    {
        return cached!;
    }

    var dto = await _db.Customers
        .AsNoTracking()
        .Where(c => c.Id == id)
        .Select(c => new CustomerDto(c.Id, c.Name, c.Email))
        .FirstAsync();

    _cache.Set(id, dto, TimeSpan.FromMinutes(5));
    return dto;
}
```

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…