Skip to content

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.

  • DirectoryGranit.Caching/ FusionCache L1 in-memory, fail-safe, encryption, OTel
    • DirectoryGranit.Caching.StackExchangeRedis/ Redis L2 + backplane + health check
PackageRoleDepends on
Granit.CachingIFusionCache with L1 in-memory, fail-safe, factory timeouts, eager refresh, EncryptingFusionCacheSerializer, OpenTelemetryGranit, Granit.Timing
Granit.Caching.StackExchangeRedisRedis L2 distributed cache, Redis pub/sub backplane, AES-256 encryption, health checkGranit.Caching
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.

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);
}
}
MethodPurpose
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)
PropertyDefaultDescription
KeyPrefix"dd"Global key prefix. FusionCache keys become {KeyPrefix}:{userKey}
DefaultAbsoluteExpirationRelativeToNow01:00:00Default TTL (mapped to FusionCache Duration)
DefaultSlidingExpiration00:20:00Default sliding expiration
EncryptValuesfalseEnable AES-256 encryption on L2 (Redis) serialization
JsonOptionsnullCustom System.Text.Json serializer options

CacheEncryptionOptions (Cache:Encryption section)

Section titled “CacheEncryptionOptions (Cache:Encryption section)”
PropertyDefaultDescription
KeyAES-256 encryption key (base64-encoded, 32 bytes)

FusionCachingOptions (Cache:FusionCache section)

Section titled “FusionCachingOptions (Cache:FusionCache section)”
PropertyDefaultDescription
FailSafeIsEnabledtrueEnable fail-safe (stale-on-error)
FailSafeMaxDuration02:00:00Maximum duration to keep fail-safe entries
FailSafeThrottleDuration00:00:30Minimum interval between fail-safe activations for the same key
FactorySoftTimeout00:00:02Soft timeout — return stale data if factory is slow
FactoryHardTimeout00:00:10Hard timeout — abandon factory execution
EagerRefreshThreshold0.8Fraction of TTL at which background refresh triggers (0 = disabled)
BackplaneChannelPrefix"granit:fc"Redis pub/sub channel prefix for backplane notifications
PropertyDefaultDescription
IsEnabledtrueEnable/disable Redis module (useful for dev override)
Configuration"localhost:6379"StackExchange.Redis connection string
InstanceName"dd:"Redis key prefix for app isolation
{
"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 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 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

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.

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.

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.

LayerStorageEncrypted
L1In-memory (per pod)No (plain .NET objects)
L2Redis (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.

FusionCache emits native OpenTelemetry metrics under the ZiggyCreatures.FusionCache meter. Key metrics include:

MetricDescription
fusioncache.cache.hitCache hits (L1 and L2)
fusioncache.cache.missCache misses
fusioncache.cache.stale_hitFail-safe activations (stale data served)
fusioncache.cache.setCache writes
fusioncache.cache.removeCache removals
fusioncache.factory.synthetic_timeoutFactory soft timeout activations
fusioncache.cache.expireExpire (mark stale) operations
fusioncache.backplane.message_publishedBackplane notifications sent
fusioncache.backplane.message_receivedBackplane notifications received

No additional configuration is needed — FusionCache automatically integrates with the OpenTelemetry SDK when it is registered in the application.

Use ExpireAsync to mark cache entries as stale. This preserves the fail-safe fallback while ensuring the next access triggers a fresh factory execution.

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.

builder.Services.AddHealthChecks()
.AddGranitRedisHealthCheck(degradedThreshold: TimeSpan.FromMilliseconds(100));
LatencyStatusEffect
< 100 msHealthyNormal
>= 100 msDegradedPod stays in load balancer
UnreachableUnhealthyPod removed from load balancer

Tagged ["readiness", "startup"] — integrates with Kubernetes readiness probes.

CategoryKey typesPackage
ModulesGranitCachingModule, GranitCachingStackExchangeRedisModule
Cache interfaceIFusionCache (from ZiggyCreatures.FusionCache)Granit.Caching
EncryptionICacheValueEncryptor, AesCacheValueEncryptor, EncryptingFusionCacheSerializerGranit.Caching
OptionsCachingOptions, CacheEncryptionOptions, FusionCachingOptions, RedisCachingOptions
ExtensionsAddGranitCaching(), AddGranitCachingRedis(), AddGranitRedisHealthCheck()
Health checkRedisHealthCheckGranit.Caching.StackExchangeRedis

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 — IMemoryCache is 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