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)
EncryptValuesfalseForce AES-256 encryption for all L2 (Redis) values. Independent of this flag, [CacheEncrypted] types are always encrypted once an encryption key resolves
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). A resolvable 256-bit key here is sufficient to arm the encryptor — EncryptValues need not be set

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 key-driven. GranitCachingStackExchangeRedisModule replaces the no-op NullCacheValueEncryptor with AesCacheValueEncryptor as soon as a 256-bit Cache:Encryption:Key resolves — you no longer have to flip EncryptValues for the encryptor to exist. The flag and the key play two distinct roles:

TriggerEffect
Cache:Encryption:Key resolves (32 bytes)Arms the encryptor. [CacheEncrypted] values are encrypted on L2
Cache:EncryptValues = trueEncrypts every L2 value, not just the [CacheEncrypted] ones

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.

Mark a cached record with [CacheEncrypted] (CacheEncryptedAttribute, from Granit.Caching) to force AES-256-GCM encryption of that type on the L2 path regardless of the global EncryptValues flag. It is a no-op on the L1 in-memory store, which holds live .NET object graphs rather than serialized bytes — the same threat model as IMemoryCache.

The framework marks BffTokenSet (BFF session tokens) and IdempotencyEntry (captured response status, headers, and body — any of which may carry PII or bearer tokens). The attribute only encrypts once an encryptor key is present: before this was enforced, a [CacheEncrypted] type on a keyless host silently fell through to the no-op encryptor and landed in Redis as plaintext. The startup validator below closes that gap.

RedisCacheEncryptionStartupValidator (an IValidateOptions<CachingOptions> wired with ValidateOnStart, armed only once an L2 Redis provider is registered) refuses to boot a non-Development host that would persist cache values in plaintext — i.e. no Cache:Encryption:Key resolves and EncryptValues is off. Development stays permissive so local Redis needs no key.

Redis L2 cache is active but no encryption key is configured and EncryptValues is off.
Cached values (including [CacheEncrypted] BFF tokens and idempotency entries) would be
written to Redis in plaintext. Set Cache:Encryption:Key (256-bit, from Vault) or, to
encrypt all values, Cache:EncryptValues=true. This check is relaxed in Development only.

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, EncryptingFusionCacheSerializer, [CacheEncrypted]Granit.Caching
Encryption guardRedisCacheEncryptionStartupValidatorGranit.Caching.StackExchangeRedis
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