Composable, production-ready caching for .NET 8. Start with an in-memory provider and swap in Redis, a hybrid tier, distributed tag invalidation, or Protobuf serialization — each as a separate NuGet package with no mandatory dependencies on things you don't use.
In large codebases, caching tends to rot. It starts as a few _memoryCache.TryGetValue calls scattered across services. Over time you get:
- Stale data bugs — a write somewhere doesn't know which cache keys to evict, so users see old data until TTL expires.
- Cache stampedes — 200 requests all miss at the same time, all hit the database simultaneously, all try to write the same key back.
- Untestable services — caching logic is baked into service methods. You can't test the business logic without also testing the cache.
- Scattered key strings —
"user:42"is typed as a raw string in a dozen places. One typo, one missed update, and invalidation breaks silently. - Provider lock-in — you chose
IMemoryCacheon day one. Now you need Redis. Everything has to change. - No observability — you have no idea what your hit rate is, which operations are cold, or how large the cache has grown.
Dragonfire.Caching addresses all of these:
| Problem | Solution |
|---|---|
| Stale data | Tag-based invalidation — one InvalidateByTagAsync("user:42") evicts every related key |
| Cache stampedes | CacheLockManager — per-key SemaphoreSlim prevents concurrent factory execution |
| Untestable services | ICacheService / ICacheProvider are interfaces — mock them freely |
| Scattered key strings | [Cache(KeyTemplate = "user:{id}")] or fluent builder — key format lives next to the method |
| Provider lock-in | Swap providers by changing one AddDragonfire* call in Program.cs |
| No observability | CacheStatistics per provider + OpenTelemetry counters via System.Diagnostics.Metrics |
Install only what you need. Every package targets net8.0.
Dragonfire.Caching ← always required (core)
Dragonfire.Caching.Memory ← in-process IMemoryCache provider
Dragonfire.Caching.Distributed ← any IDistributedCache backend
Dragonfire.Caching.Hybrid ← L1 memory + L2 distributed, auto-promote
Dragonfire.Caching.Redis ← Redis-backed tag index (distributed invalidation)
Dragonfire.Caching.Serialization.Protobuf ← swap JSON for Protobuf
dotnet add package Dragonfire.Caching
dotnet add package Dragonfire.Caching.Memory
// Program.cs
builder.Services.AddDragonfireMemoryCache(
configureMemory: o => o.SizeLimit = 50_000);// Anywhere in your app
public class OrderService(ICacheService cache)
{
public Task<Order?> GetAsync(int id) =>
cache.GetOrAddAsync(
key: $"order:{id}",
factory: () => _db.Orders.FindAsync(id).AsTask(),
configureOptions: o => o.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5));
}// Register StackExchange.Redis first (your choice of package)
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");
// Then the provider
builder.Services.AddDragonfireDistributedCache();Reads hit L1 first. On L1 miss the value is fetched from Redis and promoted to L1. Writes go to both tiers in parallel.
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");
builder.Services.AddDragonfireHybridCache(
configureMemory: o => o.SizeLimit = 10_000);For large teams, keeping TTLs and tag policies out of C# attributes and in configuration means you can tune caching without a redeploy.
{
"Caching": {
"DefaultTtlSeconds": 300,
"Operations": {
"Order.GetById": {
"TtlSeconds": 600,
"Tags": ["order:{Id}"]
},
"Order.GetByUser": {
"TtlSeconds": 120,
"Tags": ["user:{UserId}:orders"]
},
"Order.Update": {
"InvalidatesTags": ["order:{Id}", "user:{UserId}:orders"]
}
}
}
}builder.Services
.AddDragonfireMemoryCache()
.AddDragonfireCaching(builder.Configuration, configure: b =>
b.UseQueuedInvalidation(o =>
{
o.Capacity = 10_000; // bounded queue
o.DropWhenFull = false; // back-pressure (default)
o.ConsumerCount = 2; // parallel invalidation workers
}));public static class OrderOps
{
public static readonly CacheOperation GetById = new("Order.GetById");
public static readonly CacheOperation GetByUser = new("Order.GetByUser");
public static readonly CacheOperation Update = new("Order.Update");
}public class OrderService(CacheExecutor cache)
{
// Cache result. Tags registered from config ("order:99").
public Task<Order> GetByIdAsync(int id) =>
cache.GetOrCreateAsync(
OrderOps.GetById,
parameters: new { Id = id },
factory: () => _db.FindAsync(id));
// Execute action, then enqueue tag invalidation asynchronously.
public Task UpdateAsync(int id, int userId, OrderDto dto) =>
cache.ExecuteAndInvalidateAsync(
OrderOps.Update,
parameters: new { Id = id, UserId = userId },
action: () => _db.UpdateAsync(id, dto));
}The InvalidationWorker background service processes the queue and evicts all keys tagged order:99 and user:7:orders — without the service needing to know what those keys are.
Register your service with the proxy decorator. No base class, no interface changes needed.
// Program.cs
builder.Services.AddDragonFireCachedService<IOrderService, OrderService>();public class OrderService : IOrderService
{
// Cache with a template — only {id} in the key.
[Cache(KeyTemplate = "order:{id}", AbsoluteExpirationSeconds = 600)]
public virtual Task<Order?> GetByIdAsync(int id) =>
_db.Orders.FindAsync(id).AsTask();
// Use tags for group invalidation.
[Cache(
KeyTemplate = "user:{userId}:orders:{status}",
SlidingExpirationSeconds = 120,
Tags = ["user:{userId}:orders"])]
public virtual Task<List<Order>> GetByUserAsync(int userId, OrderStatus status) =>
_db.Orders.Where(o => o.UserId == userId && o.Status == status).ToListAsync();
// Evict by key pattern and tag after the write completes.
[CacheInvalidate("order:{id}:*")]
[CacheInvalidate(Tag = "user:{userId}:orders")]
public virtual Task UpdateAsync(int id, int userId, OrderDto dto) =>
_db.UpdateAsync(id, dto);
}Important: Methods must be
virtual(or on an interface) for the proxy to intercept them.
You have four options, from most explicit to least.
Name exactly the placeholders you want. Everything else is ignored.
// Method: GetOrdersAsync(int userId, OrderStatus status, bool includeDeleted, string sortBy)
// Key: "orders:42:Active" — includeDeleted and sortBy are irrelevant to the cache result
[Cache(KeyTemplate = "orders:{userId}:{status}", AbsoluteExpirationSeconds = 300)]
public virtual Task<List<Order>> GetOrdersAsync(
int userId, OrderStatus status, bool includeDeleted, string sortBy) { ... }You can also use positional placeholders ({0}, {1}, ...):
[Cache(KeyTemplate = "orders:{0}:{1}", AbsoluteExpirationSeconds = 300)]
public virtual Task<List<Order>> GetOrdersAsync(int userId, OrderStatus status, ...) { ... }Mark the parameters that matter. Only marked ones appear in the auto-generated key. Untagged ones (including CancellationToken, audit flags, etc.) are excluded.
[Cache(AbsoluteExpirationSeconds = 300)]
public virtual Task<List<Order>> GetOrdersAsync(
[CacheKey] int userId, // ✅ in key
[CacheKey] OrderStatus status, // ✅ in key
bool includeDeleted, // ❌ excluded (another param has [CacheKey])
CancellationToken ct) // ❌ excluded
// → key: "OrderService.GetOrdersAsync(userId=42,status=Active)"You can also rename a parameter in the key:
[Cache(AbsoluteExpirationSeconds = 300)]
public virtual Task<Order?> GetByIdAsync([CacheKey("id")] int orderId, string expand)
// → key: "OrderService.GetByIdAsync(id=99)"Safe when all parameters are value types or strings. Complex objects fall back to GetHashCode() which is not stable across restarts — use Option 1 or 2 for those.
[Cache(AbsoluteExpirationSeconds = 300)]
public virtual Task<Report> GenerateAsync(int year, int month, ReportType type)
// → key: "ReportService.GenerateAsync(year=2024,month=3,type=Monthly)"builder.Services.AddDragonFireCachedService<IOrderService, OrderService>(b => b
.Cache(
s => s.GetOrdersAsync(default, default, default, default),
cacheKeyTemplate: "orders:{userId}:{status}", // only these two in key
expiration: TimeSpan.FromMinutes(5))
.Invalidate(
s => s.UpdateOrderAsync(default, default),
cacheKeyTemplate: "orders:{userId}:*")); // glob wipes all statuses| Approach | Which params go in the key |
|---|---|
KeyTemplate = "x:{a}:{b}" |
Only the placeholders you list |
[CacheKey] on some params |
Only the marked params |
[CacheKey] on no params |
All params (same as next row) |
No template, no [CacheKey] |
All params (value types / strings — safe) |
Manual ICacheService |
Whatever string you build yourself |
Tags let you evict a group of related keys with a single call, regardless of how many variations are cached.
// Store with tags
await cache.GetOrAddWithTagsAsync(
key: $"user:{userId}:profile",
factory: () => _db.GetProfileAsync(userId),
tags: [$"user:{userId}"],
configureOptions: o => o.SlidingExpiration = TimeSpan.FromMinutes(10));
await cache.GetOrAddWithTagsAsync(
key: $"user:{userId}:orders:pending",
factory: () => _db.GetOrdersAsync(userId, OrderStatus.Pending),
tags: [$"user:{userId}", "orders:pending"]);
// One call evicts ALL keys tagged "user:42" — profile and orders both gone
await cache.InvalidateByTagAsync($"user:{userId}");For distributed scenarios (multiple nodes), replace the default in-memory tag index with Redis:
// Option A — from the caching builder
builder.Services.AddDragonfireCaching(b => b.UseRedisTagIndex());
// Option B — standalone registration (if IConnectionMultiplexer is already registered)
builder.Services.AddDragonfireRedisTagIndex();
// Option C — register multiplexer and tag index together
builder.Services.AddDragonfireRedisTagIndex("localhost:6379");The channel-based invalidation queue can be tuned for high-throughput scenarios.
builder.Services.AddDragonfireCaching(builder.Configuration, configure: b =>
b.UseQueuedInvalidation(o =>
{
// null = unbounded (default). Set a number to apply back-pressure.
o.Capacity = 50_000;
// When at capacity:
// false (default) = wait for space (back-pressure, no data loss)
// true = silently drop the oldest item (lossy, never blocks)
o.DropWhenFull = false;
// Number of parallel workers draining the queue.
// Increase if invalidation throughput is a bottleneck.
o.ConsumerCount = 4;
}));If you need synchronous, guaranteed invalidation before returning to the caller:
// By exact key
await cache.RemoveAsync("order:99");
// By glob pattern (all variants of an order)
await cache.RemoveByPatternAsync("order:99:*");
// By tag (all keys associated with this tag)
await cache.InvalidateByTagAsync("user:42");Note:
RemoveByPatternAsyncon theDistributedCacheProvideruses an in-process key index — it works correctly when all writes go through the same provider instance. For multi-node deployments, use tag-based invalidation withRedisTagIndexinstead.
// The factory runs at most once per key, even under concurrent load.
// Other callers wait until the first one populates the cache.
var result = await cache.GetOrAddWithLockAsync(
key: $"expensive-report:{date}",
factory: () => _reportEngine.GenerateAsync(date),
configureOptions: o => o.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),
lockTimeout: TimeSpan.FromSeconds(30));GetOrAddWithLockAsync uses CacheLockManager — per-key SemaphoreSlim locks, in-process only. For distributed locking across multiple nodes, use a library like RedLock.net and call ICacheService.GetOrAddAsync inside your own lock.
dotnet add package Dragonfire.Caching.Serialization.Protobuf
builder.Services.AddDragonfireDistributedCache(configure: b =>
b.UseProtobufSerializer());builder.Services.AddDragonfireMemoryCache(configureCaching: b =>
b.UseJsonSerializer(new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
Converters = { new JsonStringEnumConverter() }
}));Implement ICacheSerializer and register it:
public class MessagePackCacheSerializer : ICacheSerializer
{
public byte[] Serialize<T>(T value) => MessagePackSerializer.Serialize(value);
public T Deserialize<T>(byte[] bytes) => MessagePackSerializer.Deserialize<T>(bytes);
}
builder.Services.AddDragonfireMemoryCache(configureCaching: b =>
b.UseSerializer<MessagePackCacheSerializer>());By default, auto-generated keys look like OrderService.GetByIdAsync(id=99). Replace the strategy globally:
public class PrefixedKeyStrategy : DefaultCacheKeyStrategy
{
private readonly string _prefix;
public PrefixedKeyStrategy(string prefix) => _prefix = prefix;
public override string GenerateKey(MethodInfo method, object?[] arguments, string? keyTemplate = null)
=> $"{_prefix}:{base.GenerateKey(method, arguments, keyTemplate)}";
}
builder.Services.AddSingleton<ICacheKeyStrategy>(new PrefixedKeyStrategy("myapp"));// Replace the default in-memory tag index with any implementation:
builder.Services.AddDragonfireCaching(b =>
b.UseTagIndex<MyCustomTagIndex>());Implement ICacheProvider and register it:
public class DynamoDbCacheProvider : ICacheProvider { ... }
builder.Services
.AddDragonfireCaching()
.AddDragonfireCacheProvider<DynamoDbCacheProvider>();// Absolute — expires at a fixed point in time
o.AbsoluteExpiration = DateTimeOffset.UtcNow.AddHours(2);
// Absolute relative to now — most common
o.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
// Sliding — expiration resets on every cache hit
o.SlidingExpiration = TimeSpan.FromMinutes(10);
// Convenience factory methods
var opts = CacheEntryOptions.Absolute(TimeSpan.FromMinutes(5));
var opts = CacheEntryOptions.Sliding(TimeSpan.FromMinutes(10));
var opts = CacheEntryOptions.NeverExpire; // Priority = NeverRemove (memory only)
// Tags + expiration together
await cache.SetAsync("key", value, o =>
{
o.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
o.Tags.Add("user:42");
o.Priority = CacheItemPriority.High; // memory provider only
});// Read many keys at once
IDictionary<string, User?> users = await provider.GetMultipleAsync<User>(
["user:1", "user:2", "user:3"]);
// Write many keys at once
await provider.SetMultipleAsync(
new Dictionary<string, User>
{
["user:1"] = user1,
["user:2"] = user2,
},
CacheEntryOptions.Absolute(TimeSpan.FromMinutes(10)));public class CacheDiagnosticsController(ICacheService cache) : ControllerBase
{
[HttpGet("cache/stats")]
public IActionResult Stats()
{
var s = cache.GetStatistics();
return Ok(new
{
provider = cache.ProviderName,
hitRatio = s.HitRatio.ToString("P1"), // "94.3%"
hits = s.TotalHits,
misses = s.TotalMisses,
sets = s.TotalSets,
removals = s.TotalRemovals,
entryCount = s.CurrentEntryCount,
sinceUtc = s.LastReset
});
}
}The library records four counters under the Dragonfire.Caching meter:
| Metric name | Tags | Description |
|---|---|---|
dragonfire.cache.hits |
provider |
Total cache hits |
dragonfire.cache.misses |
provider |
Total cache misses |
dragonfire.cache.sets |
provider |
Total cache sets |
dragonfire.cache.removals |
provider |
Total cache removals |
// Prometheus via OpenTelemetry
builder.Services.AddOpenTelemetry()
.WithMetrics(m => m
.AddMeter("Dragonfire.Caching")
.AddPrometheusExporter());Before:
if (!_cache.TryGetValue($"user:{id}", out User user))
{
user = await _db.Users.FindAsync(id);
_cache.Set($"user:{id}", user, TimeSpan.FromMinutes(5));
}
return user;After:
return await _cache.GetOrAddAsync(
$"user:{id}",
() => _db.Users.FindAsync(id).AsTask(),
o => o.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5));To swap providers later, change only Program.cs — nothing in your services changes.
┌─────────────────────────────────────────────────────────────┐
│ Your application │
│ │
│ ICacheService ──────────────────────────────────────────┐ │
│ │ │ │
│ CacheExecutor (config-driven, with queued invalidation) │ │
│ │ │ │
│ CachingProxy<T> (attribute or fluent decorator) │ │
└───────┼──────────────────────────────────────────────────┼──┘
│ │
▼ ▼
ICacheProvider ITagIndex
├─ MemoryCacheProvider (Dragonfire.Caching.Memory)
├─ DistributedCacheProvider (Dragonfire.Caching.Distributed)
└─ HybridCacheProvider (Dragonfire.Caching.Hybrid)
│ ├─ InMemoryTagIndex (core)
▼ └─ RedisTagIndex (Dragonfire.Caching.Redis)
ICacheSerializer
├─ SystemTextJsonSerializer (core, default)
└─ ProtobufSerializer (Dragonfire.Caching.Serialization.Protobuf)
IInvalidationQueue ──► InvalidationWorker (BackgroundService)
└─ ChannelInvalidationQueue (configurable capacity + consumers)
// Program.cs — hybrid provider, Redis tags, queued invalidation
builder.Services
.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379")
.AddDragonfireHybridCache(
configureMemory: o => o.SizeLimit = 10_000,
configureCaching: b => b
.UseQueuedInvalidation(o => { o.Capacity = 20_000; o.ConsumerCount = 2; })
.UseRedisTagIndex())
.AddDragonfireCaching(builder.Configuration); // binds CacheSettings from appsettings
// Register a service with the proxy decorator
builder.Services.AddDragonFireCachedService<IOrderService, OrderService>();// OrderService.cs
public class OrderService : IOrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db) => _db = db;
[Cache(
KeyTemplate = "order:{id}",
AbsoluteExpirationSeconds = 600,
Tags = ["order:{id}", "user:{userId}:orders"])]
public virtual Task<Order?> GetByIdAsync(int id, int userId) =>
_db.Orders.FindAsync(id).AsTask();
[Cache(
KeyTemplate = "user:{userId}:orders:{status}",
SlidingExpirationSeconds = 120,
Tags = ["user:{userId}:orders"])]
public virtual Task<List<Order>> GetByUserAsync(
[CacheKey] int userId,
[CacheKey] OrderStatus status,
bool includeArchived, // not in cache key
CancellationToken ct = default) =>
_db.Orders
.Where(o => o.UserId == userId && o.Status == status)
.ToListAsync(ct);
[CacheInvalidate("order:{id}:*")]
[CacheInvalidate(Tag = "user:{userId}:orders")]
public virtual async Task UpdateAsync(int id, int userId, OrderDto dto)
{
await _db.UpdateOrderAsync(id, dto);
}
}