Caching — FusionCache for .NET
Granit.Caching integrates FusionCache as the
caching layer. Consumers inject IFusionCache directly — there is no custom abstraction.
Out of the box you get L1 in-memory cache with fail-safe (stale-on-error), soft/hard factory
timeouts, eager background refresh, stampede protection, and native OpenTelemetry metrics.
Add the Redis package for L2 distributed cache with AES-256 encryption and a pub/sub backplane
for real-time cross-pod L1 invalidation.
Package structure
Section titled “Package structure”DirectoryGranit.Caching/ FusionCache L1 in-memory, fail-safe, encryption, OTel
DirectoryGranit.Caching.StackExchangeRedis/ Redis L2 + backplane + health check
- …
| Package | Role | Depends on |
|---|---|---|
Granit.Caching | IFusionCache with L1 in-memory, fail-safe, factory timeouts, eager refresh, EncryptingFusionCacheSerializer, OpenTelemetry | Granit, Granit.Timing |
Granit.Caching.StackExchangeRedis | Redis L2 distributed cache, Redis pub/sub backplane, AES-256 encryption, health check | Granit.Caching |
Dependency graph
Section titled “Dependency graph”graph TD
C[Granit.Caching] --> CO[Granit]
C --> T[Granit.Timing]
R[Granit.Caching.StackExchangeRedis] --> C
[DependsOn(typeof(GranitCachingModule))]public class AppModule : GranitModule { }No configuration needed. L1 in-memory cache with fail-safe and factory timeouts enabled
by default. Uses MemoryDistributedCache as a no-op L2 backend.
[DependsOn(typeof(GranitCachingStackExchangeRedisModule))]public class AppModule : GranitModule { }{ "Cache": { "KeyPrefix": "myapp", "EncryptValues": true, "Encryption": { "Key": "<base64-aes256-key-from-vault>" }, "Redis": { "Configuration": "redis-service:6379,password=secret,ssl=true", "InstanceName": "myapp:" } }}GranitCachingStackExchangeRedisModule upgrades the FusionCache instance registered by GranitCachingModule
with L2 Redis and a Redis pub/sub backplane. It also enables AES-256 encryption when
EncryptValues is true.
Quick start
Section titled “Quick start”Inject IFusionCache (from ZiggyCreatures.Caching.Fusion) and use the FusionCache API
directly. No custom abstraction layer.
using ZiggyCreatures.Caching.Fusion;
public sealed class PatientService(IFusionCache cache, AppDbContext db){ public async Task<PatientResponse?> GetAsync( Guid id, CancellationToken cancellationToken) { return await cache.GetOrSetAsync<PatientResponse>( $"patient:{id}", async (ctx, ct) => { var patient = await db.Patients .Where(p => p.Id == id) .Select(p => new PatientResponse(p.Id, p.FullName, p.DateOfBirth)) .FirstOrDefaultAsync(ct) .ConfigureAwait(false);
return patient!; }, token: cancellationToken).ConfigureAwait(false); }
public async Task InvalidateAsync( Guid id, CancellationToken cancellationToken) { // Mark stale — fail-safe keeps the entry as fallback await cache.ExpireAsync($"patient:{id}", token: cancellationToken) .ConfigureAwait(false); }}Common API methods
Section titled “Common API methods”| Method | Purpose |
|---|---|
GetOrSetAsync<T>(key, factory, options?, token:) | Get from cache or populate via factory |
GetOrDefaultAsync<T>(key, token:) | Get only, returns default(T) on miss |
SetAsync<T>(key, value, options?, token:) | Store a value directly |
ExpireAsync(key, token:) | Mark stale (fail-safe keeps it as fallback) |
RemoveAsync(key, token:) | Delete completely (no fail-safe fallback) |
TryGetAsync<T>(key, token:) | Returns MaybeValue<T> (check .HasValue) |
Configuration
Section titled “Configuration”CachingOptions (Cache section)
Section titled “CachingOptions (Cache section)”| Property | Default | Description |
|---|---|---|
KeyPrefix | "dd" | Global key prefix. FusionCache keys become {KeyPrefix}:{userKey} |
DefaultAbsoluteExpirationRelativeToNow | 01:00:00 | Default TTL (mapped to FusionCache Duration) |
DefaultSlidingExpiration | 00:20:00 | Default sliding expiration |
EncryptValues | false | Enable AES-256 encryption on L2 (Redis) serialization |
JsonOptions | null | Custom System.Text.Json serializer options |
CacheEncryptionOptions (Cache:Encryption section)
Section titled “CacheEncryptionOptions (Cache:Encryption section)”| Property | Default | Description |
|---|---|---|
Key | — | AES-256 encryption key (base64-encoded, 32 bytes) |
FusionCachingOptions (Cache:FusionCache section)
Section titled “FusionCachingOptions (Cache:FusionCache section)”| Property | Default | Description |
|---|---|---|
FailSafeIsEnabled | true | Enable fail-safe (stale-on-error) |
FailSafeMaxDuration | 02:00:00 | Maximum duration to keep fail-safe entries |
FailSafeThrottleDuration | 00:00:30 | Minimum interval between fail-safe activations for the same key |
FactorySoftTimeout | 00:00:02 | Soft timeout — return stale data if factory is slow |
FactoryHardTimeout | 00:00:10 | Hard timeout — abandon factory execution |
EagerRefreshThreshold | 0.8 | Fraction of TTL at which background refresh triggers (0 = disabled) |
BackplaneChannelPrefix | "granit:fc" | Redis pub/sub channel prefix for backplane notifications |
RedisCachingOptions (Cache:Redis section)
Section titled “RedisCachingOptions (Cache:Redis section)”| Property | Default | Description |
|---|---|---|
IsEnabled | true | Enable/disable Redis module (useful for dev override) |
Configuration | "localhost:6379" | StackExchange.Redis connection string |
InstanceName | "dd:" | Redis key prefix for app isolation |
Full configuration example
Section titled “Full configuration example”{ "Cache": { "KeyPrefix": "myapp", "DefaultAbsoluteExpirationRelativeToNow": "01:00:00", "EncryptValues": true, "Encryption": { "Key": "<base64-aes256-key>" }, "FusionCache": { "FailSafeIsEnabled": true, "FailSafeMaxDuration": "02:00:00", "FailSafeThrottleDuration": "00:00:30", "FactorySoftTimeout": "00:00:02", "FactoryHardTimeout": "00:00:10", "EagerRefreshThreshold": 0.8, "BackplaneChannelPrefix": "granit:fc" }, "Redis": { "IsEnabled": true, "Configuration": "redis-service:6379,password=secret,ssl=true", "InstanceName": "myapp:" } }}Fail-safe
Section titled “Fail-safe”Fail-safe keeps expired entries as a temporary fallback when the factory (database query, HTTP call) fails or times out. Instead of propagating a 500 error to the user, FusionCache serves the last known good value.
sequenceDiagram
participant C as Consumer
participant FC as FusionCache
participant DB as Database
C->>FC: GetOrSetAsync("patient:123")
FC->>FC: Entry expired (stale)
FC->>DB: Execute factory
DB--xFC: Connection refused
FC->>FC: Fail-safe activated
FC-->>C: Stale PatientResponse (200 OK)
Note over FC: Throttle: retry after 30s
Fail-safe is enabled by default (FailSafeIsEnabled = true). The FailSafeMaxDuration
(default 2 hours) controls how long stale entries are kept. The FailSafeThrottleDuration
(default 30 seconds) prevents retry storms by spacing out factory re-executions.
Factory timeouts
Section titled “Factory timeouts”Factory timeouts protect the HTTP request pipeline from slow downstream calls:
- Soft timeout (default 2 seconds): if the factory is still running and a stale value exists, return the stale value immediately. The factory continues in the background and updates the cache when it completes.
- Hard timeout (default 10 seconds): if the factory exceeds this limit, it is abandoned regardless of stale data availability.
sequenceDiagram
participant C as Consumer
participant FC as FusionCache
participant DB as Database
C->>FC: GetOrSetAsync("patient:123")
FC->>FC: Entry expired (stale exists)
FC->>DB: Execute factory
Note over FC,DB: Factory running > 2s (soft timeout)
FC-->>C: Stale PatientResponse (immediate)
DB-->>FC: Fresh data (5s later)
FC->>FC: Update cache silently
Eager refresh
Section titled “Eager refresh”When EagerRefreshThreshold is set (default 0.8 = 80%), FusionCache triggers a background
refresh when a request arrives after 80% of the TTL has elapsed. The current (still valid)
value is returned immediately while the factory runs in the background.
With a 1-hour TTL and 0.8 threshold, any request arriving after 48 minutes triggers a background refresh. Consumers never see expired data under normal traffic patterns.
Set EagerRefreshThreshold to 0 to disable eager refresh.
Backplane
Section titled “Backplane”In multi-pod Kubernetes deployments, each pod has its own L1 in-memory cache. Without coordination, invalidating a cache entry on pod A leaves pods B and C serving stale L1 data.
The Redis backplane solves this via pub/sub notifications:
sequenceDiagram
participant A as Pod A
participant R as Redis (pub/sub)
participant B as Pod B
participant C as Pod C
A->>A: ExpireAsync("patient:123")
A->>R: Publish invalidation
R->>B: Notify
R->>C: Notify
B->>B: Evict L1 "patient:123"
C->>C: Evict L1 "patient:123"
The backplane is automatically enabled when GranitCachingStackExchangeRedisModule is loaded. The
BackplaneChannelPrefix (default "granit:fc") is used as the Redis pub/sub channel prefix.
FusionCache auto-recovery ensures that transient Redis disconnections do not lose invalidation
messages — they are replayed when the connection is restored.
Encryption
Section titled “Encryption”EncryptingFusionCacheSerializer is a decorator around the SystemTextJson serializer that
applies AES-256-GCM authenticated encryption to all L2 (Redis) traffic. L1 in-memory cache
stores plain .NET objects — same threat model as IMemoryCache.
| Layer | Storage | Encrypted |
|---|---|---|
| L1 | In-memory (per pod) | No (plain .NET objects) |
| L2 | Redis (shared) | Yes (when EncryptValues = true) |
Encryption is activated by setting CachingOptions.EncryptValues = true and providing
an AES-256 key in CacheEncryptionOptions.Key. The GranitCachingStackExchangeRedisModule replaces
the no-op NullCacheValueEncryptor with AesCacheValueEncryptor at startup.
Each encryption operation generates a random 12-byte nonce and produces a 16-byte
authentication tag. Ciphertext format: [12B nonce][16B tag][NB ciphertext].
Tampered values are detected and rejected via the GCM tag.
OpenTelemetry
Section titled “OpenTelemetry”FusionCache emits native OpenTelemetry metrics under the ZiggyCreatures.FusionCache meter.
Key metrics include:
| Metric | Description |
|---|---|
fusioncache.cache.hit | Cache hits (L1 and L2) |
fusioncache.cache.miss | Cache misses |
fusioncache.cache.stale_hit | Fail-safe activations (stale data served) |
fusioncache.cache.set | Cache writes |
fusioncache.cache.remove | Cache removals |
fusioncache.factory.synthetic_timeout | Factory soft timeout activations |
fusioncache.cache.expire | Expire (mark stale) operations |
fusioncache.backplane.message_published | Backplane notifications sent |
fusioncache.backplane.message_received | Backplane notifications received |
No additional configuration is needed — FusionCache automatically integrates with the OpenTelemetry SDK when it is registered in the application.
Cache invalidation
Section titled “Cache invalidation”Use ExpireAsync to mark cache entries as stale. This preserves the fail-safe fallback
while ensuring the next access triggers a fresh factory execution.
Pattern: Wolverine event handler
Section titled “Pattern: Wolverine event handler”Granit modules use Wolverine message handlers for cache invalidation. When a domain event is raised (e.g., permission grant changed), the handler expires the corresponding cache key:
using ZiggyCreatures.Caching.Fusion;
public static class PermissionCacheInvalidationHandler{ public static async Task HandleAsync( PermissionGrantChangedEvent @event, IFusionCache cache, CancellationToken cancellationToken) { string key = PermissionChecker.BuildCacheKey( @event.TenantId, @event.RoleName, @event.PermissionName); await cache.ExpireAsync(key, token: cancellationToken) .ConfigureAwait(false); }}This pattern is used across Authorization, Features, Settings, Localization, and ReferenceData modules.
Redis health check
Section titled “Redis health check”builder.Services.AddHealthChecks() .AddGranitRedisHealthCheck(degradedThreshold: TimeSpan.FromMilliseconds(100));| Latency | Status | Effect |
|---|---|---|
| < 100 ms | Healthy | Normal |
| >= 100 ms | Degraded | Pod stays in load balancer |
| Unreachable | Unhealthy | Pod removed from load balancer |
Tagged ["readiness", "startup"] — integrates with Kubernetes readiness probes.
Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Modules | GranitCachingModule, GranitCachingStackExchangeRedisModule | — |
| Cache interface | IFusionCache (from ZiggyCreatures.FusionCache) | Granit.Caching |
| Encryption | ICacheValueEncryptor, AesCacheValueEncryptor, EncryptingFusionCacheSerializer | Granit.Caching |
| Options | CachingOptions, CacheEncryptionOptions, FusionCachingOptions, RedisCachingOptions | — |
| Extensions | AddGranitCaching(), AddGranitCachingRedis(), AddGranitRedisHealthCheck() | — |
| Health check | RedisHealthCheck | Granit.Caching.StackExchangeRedis |
When to use — and when not to
Section titled “When to use — and when not to”Use Granit.Caching when:
- You need L1 + L2 caching with fail-safe resilience and factory timeouts
- You run multiple pods and need a backplane for real-time cross-pod L1 invalidation
- You store GDPR-sensitive data in Redis and need AES-256 encryption at rest
- You want native OpenTelemetry metrics for cache observability
- You need stampede protection (one factory execution per key under concurrent requests)
Skip it when:
- You only cache a handful of values in a single-pod app —
IMemoryCacheis simpler - Your cache values are never shared across requests — a local variable is enough
- You need full-text search or complex querying on cached data — Redis is a cache, not a database
Common pitfalls
Section titled “Common pitfalls”See also
Section titled “See also”- ADR-018: FusionCache — Why FusionCache was chosen as the caching provider
- ADR-002: Redis — Why Redis was chosen as the distributed cache backend
- FusionCache documentation — Official FusionCache docs
- Persistence module — EF Core interceptors, query filters
- API Reference (auto-generated from XML docs)