FireflyFramework.Cache is the distributed-cache abstraction tier
of the Firefly framework. It exposes a single async port —
ICacheAdapter — and ships three production adapters (Memory, Redis,
NoOp) plus a transparent primary/fallback composite
(FireflyCacheManager). Mirrors org.fireflyframework:firefly-common-cache
one-to-one.
The single-port design is deliberate. Every consumer
(CQRS query cache, idempotency middleware, OAuth2 token cache, custom
service code) talks to the same ICacheAdapter interface, regardless
of whether you're running an in-memory cache during local dev or a
Redis cluster in production. Swapping the backend is a one-line
configuration change.
Spring's @Cacheable and ASP.NET's IDistributedCache are both
procedural abstractions — you call them in your code, and the cache
either holds the value or doesn't. The Firefly cache adds three things
the platform abstractions don't:
- Transparent fallback. If Redis is unreachable,
FireflyCacheManagerroutes operations to a local Memory adapter instead of failing the request — at the cost of one process's worth of staleness. - First-class statistics + health.
GetStatsAsyncandGetHealthAsyncare part of the contract, so observability wiring is uniform across adapter choices. - Prefix eviction.
EvictByPrefixAsyncis the primitive used by the CQRS event-driven cache invalidator and the orchestration query projection — neitherIDistributedCachenor Redis'sKEYS *are appropriate (the former lacks the operation, the latter blocks the server).
┌───────────────────────────────────────────────────────────────┐
│ ICacheAdapter (port) │
└───────────────────────────────────────────────────────────────┘
▲ ▲ ▲
│ │ │
┌────────┴───────┐ ┌───────┴───────┐ ┌───────┴────────┐
│ MemoryAdapter │ │ RedisAdapter │ │ NoopAdapter │
│ in-process │ │ distributed │ │ disabled │
│ Microsoft. │ │ StackExchange │ │ always misses │
│ Caching.Memory │ │ .Redis │ │ │
└────────────────┘ └───────────────┘ └────────────────┘
(composed transparently by FireflyCacheManager)
┌────────────────┐ primary unhealthy ┌────────────────┐
────► │ FireflyCacheManager │ ─────────────────► │ fallback │
│ primary slot │ │ (typically │
│ │ ◄──── primary back ──── │ Memory) │
└────────────────┘ └────────────────┘
FireflyCacheManager is itself an ICacheAdapter, so every consumer
remains insulated from the failover behaviour.
using FireflyFramework.Cache.Core;
using FireflyFramework.Cache.DependencyInjection;
builder.Services.AddFireflyCache(builder.Configuration);
// In your service:
var cache = sp.GetRequiredService<ICacheAdapter>();
await cache.PutAsync("user:42", user, TimeSpan.FromMinutes(15));
var hit = await cache.GetAsync<User>("user:42");
if (hit is null) { /* miss */ }That's the whole everyday API. The configuration section
(Firefly:Cache) decides which adapter is used.
The unified async contract every adapter implements.
| Member | Behaviour |
|---|---|
CacheType |
Enum value identifying the backing store |
CacheName |
Friendly name surfaced in logs / metrics |
IsAvailable |
Quick liveness flag — false if the backend is down |
GetAsync<T>(key, ct) |
Returns the deserialised value or default(T) on miss |
PutAsync<T>(key, value, ct) |
Stores with the adapter's default TTL |
PutAsync<T>(key, v, ttl, ct) |
Stores with an explicit TTL |
PutIfAbsentAsync<T> |
SETNX semantics; returns true only if the key was inserted |
EvictAsync(key, ct) |
Returns true if a key was removed |
EvictByPrefixAsync(prefix, ct) |
Bulk-removes by prefix; returns the count |
ClearAsync(ct) |
Wipes every entry under the configured prefix (use sparingly) |
ExistsAsync(key, ct) |
Cheaper than GetAsync when you only need presence |
KeysAsync(ct) |
Returns all known keys (Redis: SCAN; Memory: index map) |
SizeAsync(ct) |
Number of entries in the cache |
GetStatsAsync(ct) |
CacheStats (hits, misses, evictions, hit ratio) |
GetHealthAsync(ct) |
CacheHealth (status, latency, last error) |
| Adapter | Backing store | Notes |
|---|---|---|
MemoryCacheAdapter |
IMemoryCache + key index |
In-process; supports TTL, prefix eviction, size accounting |
RedisCacheAdapter |
StackExchange.Redis | Distributed; uses Redis SCAN (non-blocking) for prefix eviction; JSON value serialisation |
NoopCacheAdapter |
None | Drop-in for tests or environments where caching is intentionally disabled |
FireflyCacheManager |
Composite primary + fallback | Transparent failover when the primary adapter reports unhealthy |
Firefly:Cache:Provider accepts:
| Value | Effect |
|---|---|
Memory |
Always uses MemoryCacheAdapter |
Redis |
Always uses RedisCacheAdapter; throws on startup if no connection string |
NoOp |
Always uses NoopCacheAdapter (every Get returns default, every Put silently drops) |
Auto |
Picks Redis if Redis.ConnectionString is configured, Memory otherwise |
public sealed record CacheStats(
CacheType Type,
string Name,
long Operations,
long Hits,
long Misses,
long Puts,
long Evictions,
long Errors,
TimeSpan AverageLatency,
long EstimatedSizeBytes,
DateTimeOffset AsOf);Hits / (Hits + Misses) is the canonical hit-ratio. Surface it on
your dashboard alongside p99 latency to spot regressions when a code
change reorders cache lookups.
public sealed record CacheHealth(
CacheType Type,
string Name,
HealthStatus Status, // Healthy | Degraded | Unhealthy
string? LastError,
DateTimeOffset? LastSuccessfulOperation,
TimeSpan? LatencyToBackend);CacheHealth.Healthy(...) and CacheHealth.Unhealthy(...) are the
canonical builders; the latter accepts a reason string surfaced
verbatim to operators.
ICacheSerializer SPI plus the default JsonCacheSerializer (System.Text.Json).
Replace with a binary serialiser by registering an alternative
implementation before AddFireflyCache:
services.AddSingleton<ICacheSerializer, MessagePackCacheSerializer>();
services.AddFireflyCache(configuration);The serialiser interface is byte[] Serialize<T>(T) /
T? Deserialize<T>(byte[]) — anything implementing it is composable.
{
"Firefly": {
"Cache": {
"Provider": "Redis",
"Name": "default",
"KeyPrefix": "firefly:cache:",
"Redis": {
"ConnectionString": "localhost:6379",
"DefaultTtl": "00:15:00"
},
"Memory": {
"SizeLimit": 100000
}
}
}
}| Property | Default | Purpose |
|---|---|---|
Provider |
Auto |
Selects the active adapter |
Name |
default |
Friendly name in logs / metrics |
KeyPrefix |
firefly:cache: |
Namespacing prefix prepended to every key |
Redis.ConnectionString |
localhost:6379 |
StackExchange.Redis configuration string |
Redis.DefaultTtl |
(none) | Optional default expiry when PutAsync(key, value) is used without an explicit TTL |
Memory.SizeLimit |
(unbounded) | Max number of in-process entries before LRU eviction |
KeyPrefix is what makes it safe for two services to share a Redis —
each service uses its own prefix, and the EvictByPrefixAsync operation
is bounded to that prefix on the Redis SCAN side.
public async Task<UserDto?> GetUserAsync(Guid id, CancellationToken ct)
{
var key = $"user:{id}";
var hit = await cache.GetAsync<UserDto>(key, ct);
if (hit is not null) return hit;
var fresh = await repository.GetUserAsync(id, ct);
if (fresh is not null)
{
await cache.PutAsync(key, fresh, TimeSpan.FromMinutes(15), ct);
}
return fresh;
}public async Task UpdateUserAsync(UserDto user, CancellationToken ct)
{
await repository.UpdateUserAsync(user, ct);
await cache.EvictAsync($"user:{user.Id}", ct);
await cache.EvictByPrefixAsync($"user:{user.Id}:", ct); // related projections
}PutIfAbsentAsync returns true if (and only if) the key was not
already present — i.e. SETNX semantics — so you can use it as a
poor-man's distributed lock under Redis:
var lockKey = $"lock:user:{id}";
var acquired = await cache.PutIfAbsentAsync(lockKey, ownerId,
TimeSpan.FromSeconds(30), ct);
if (!acquired) return Conflict("another writer holds the lock");
try
{
/* critical section */
}
finally
{
await cache.EvictAsync(lockKey, ct);
}This is best-effort — in a network partition both sides may believe
they hold the lock. If you need true mutex guarantees, use a
purpose-built primitive (Redlock.NET, ZooKeeper, …).
The CQRS event-driven cache invalidator uses EvictByPrefixAsync to
clear all derived projections of an aggregate when its state changes.
You can do the same in your own code:
async Task OnOrderShipped(OrderShippedEvent e, CancellationToken ct)
{
await cache.EvictByPrefixAsync($"order:{e.OrderId}:", ct);
await cache.EvictByPrefixAsync($"customer:{e.CustomerId}:orders:", ct);
}var primary = new RedisCacheAdapter(mux, serializer);
var fallback = new MemoryCacheAdapter(memoryCache, serializer);
var manager = new FireflyCacheManager(primary, log, fallback);
services.AddSingleton<ICacheAdapter>(manager);Operations route to Redis when it's healthy, slip to the in-process
Memory adapter when Redis is degraded, and resume on Redis the moment
IsAvailable flips back. Be aware: the fallback diverges from Redis
during the outage — any writes during the partition stay local. Plan
for stale reads on the rejoining instance.
KeysAsyncandSizeAsyncenumerate the keyspace. On Redis, this uses non-blocking SCAN, but it's still a full scan over the prefix. Don't call them on every request — they're for diagnostics and admin tooling, not hot-path code.ClearAsynconly clears the configured prefix. Two services with differentKeyPrefixvalues won't step on each other.EvictByPrefixAsyncdoes not pre-read the keys. On the in-memory adapter the index is local; on Redis the keys are streamed via SCAN. Either way, the operation is bounded by the prefix's cardinality. Don't pass an empty prefix unless you mean it.PutAsync(key, value)(no TTL) defers to the adapter's default. Memory uses no TTL by default (entries live forever); Redis usesRedis.DefaultTtlwhen configured, otherwise no TTL. If you don't want unbounded retention, always pass an explicit TTL or setRedis.DefaultTtl.- Stats are best-effort and adapter-local. The Memory adapter counts hits/misses in process; the Redis adapter counts only the ones routed through this process. Aggregating across instances is the dashboard's job, not the adapter's.
FireflyCacheManagerdoes not synchronise primary and fallback. Writes go to whichever is currently active. If the primary becomes healthy after the fallback served writes, those writes are not back-filled. Treat the fallback as a "degraded mode" cache, not a strict replica.- JSON serialisation rejects polymorphic values without converters.
If you cache a base type with derived instances, register a custom
JsonSerializerOptionsvia your ownICacheSerializerimplementation.
- The
MemoryCacheAdapterkeeps a parallelConcurrentDictionary<string, byte>index next toIMemoryCacheso it can implementKeysAsync,SizeAsync, andEvictByPrefixAsync— operations the underlying abstraction doesn't expose. Eviction callbacks remove the index entry to keep them in sync. - The
RedisCacheAdapterusesserver.KeysAsync(pattern: ...)rather thanKEYSbecause the latter blocks the server on large keyspaces. SCAN trades latency for non-blocking semantics — the right call for production. JsonCacheSerializerreturnsbyte[]directly to skip the intermediate string allocation; Redis transports binary natively.FireflyCacheManager.Activeis computed per call (a singleIsAvailableread) — no background polling thread, no state-machine. Failover happens at access time.
| Reference | Used for |
|---|---|
FireflyFramework.Kernel |
Base exception type |
Microsoft.Extensions.Caching.Memory |
MemoryCacheAdapter |
StackExchange.Redis |
RedisCacheAdapter |
System.Text.Json (used by the default serialiser) ships in the .NET
framework — no package import needed.
| .NET | Java |
|---|---|
ICacheAdapter |
CacheAdapter |
MemoryCacheAdapter |
CaffeineCacheAdapter |
RedisCacheAdapter |
RedisCacheAdapter |
NoopCacheAdapter |
(no direct equivalent — replicates Spring's "no caching" profile) |
FireflyCacheManager |
FireflyCacheManager |
JsonCacheSerializer |
JsonCacheSerializer |
FireflyCacheOptions |
CacheProperties |
CacheStats |
CacheStats |
CacheHealth |
CacheHealth |