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.
Scanned 9/6/2026
Install to Claude Code
npx -y skills add AgenticPawan/FullStack-Pilot --skill dotnet-caching --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Dotnet Caching?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/agenticpawan-dotnet-caching)More formats (shields.io, HTML) on the badges page.
---
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;
}
```
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!