Configure Caching -- FusionCache L1/L2
Granit uses FusionCache as its caching provider. Two packages cover all deployment scenarios:
Granit.Caching— L1 in-memory cache with fail-safe, eager refresh, and OpenTelemetry. Ideal for development and tests.Granit.Caching.StackExchangeRedis— Upgrades to Redis L2 distributed cache with a pub/sub backplane for cross-pod L1 invalidation. Add this for production.
You inject IFusionCache everywhere — the same API works regardless of the
underlying topology.
Prerequisites
Section titled “Prerequisites”- A working Granit application
- Redis server (for production setups)
Development setup (memory-only)
Section titled “Development setup (memory-only)”-
Install the package
Terminal window dotnet add package Granit.Caching -
Register the module
[DependsOn(typeof(GranitCachingModule))]public sealed class AppModule : GranitModule { }No Redis, no additional configuration.
GranitCachingModuleregistersIFusionCachewith an L1 in-memory cache backed byMemoryDistributedCache. -
Configure (optional)
appsettings.Development.json {"Cache": {"KeyPrefix": "myapp"}}
Production setup (Redis L2 + backplane)
Section titled “Production setup (Redis L2 + backplane)”-
Install both packages
Terminal window dotnet add package Granit.Cachingdotnet add package Granit.Caching.StackExchangeRedis -
Register both modules
[DependsOn(typeof(GranitCachingModule),typeof(GranitCachingStackExchangeRedisModule))]public sealed class AppModule : GranitModule { }GranitCachingStackExchangeRedisModuledepends onGranitCachingModule— if you only declareGranitCachingStackExchangeRedisModule, the base module is pulled in automatically. -
Configure appsettings.json
appsettings.Production.json {"Cache": {"KeyPrefix": "myapp","DefaultAbsoluteExpirationRelativeToNow": "01:00:00","EncryptValues": true,"Encryption": {"Key": "<base64-aes256-key-from-vault>"},"Redis": {"IsEnabled": true,"Configuration": "redis-service:6379","InstanceName": "myapp:"},"FusionCache": {"FailSafeIsEnabled": true,"FactorySoftTimeout": "00:00:02","FactoryHardTimeout": "00:00:10","EagerRefreshThreshold": 0.8}}}
appsettings.json configuration reference
Section titled “appsettings.json configuration reference”Three configuration sections control caching behavior.
Cache section (CachingOptions)
Section titled “Cache section (CachingOptions)”| Property | Type | Default | Description |
|---|---|---|---|
KeyPrefix | string | "dd" | Prefix for all cache keys. Format: {KeyPrefix}:{userKey} |
DefaultAbsoluteExpirationRelativeToNow | TimeSpan? | 1h | Default entry duration (TTL) |
DefaultSlidingExpiration | TimeSpan? | 20min | Sliding expiration (reset on each access) |
EncryptValues | bool | false | Enable AES-256 encryption globally on L2 |
JsonOptions | JsonSerializerOptions? | null | Custom JSON serialization options |
Cache:Redis section (RedisCachingOptions)
Section titled “Cache:Redis section (RedisCachingOptions)”| Property | Type | Default | Description |
|---|---|---|---|
IsEnabled | bool | true | Set false to disable Redis without changing modules |
Configuration | string | "localhost:6379" | StackExchange.Redis connection string |
InstanceName | string | "dd:" | Redis key prefix for multi-app isolation |
Cache:FusionCache section (FusionCachingOptions)
Section titled “Cache:FusionCache section (FusionCachingOptions)”| Property | Type | Default | Description |
|---|---|---|---|
FailSafeIsEnabled | bool | true | Serve expired entries as fallback when the factory fails |
FailSafeMaxDuration | TimeSpan | 2h | Max duration to keep fail-safe entries available |
FailSafeThrottleDuration | TimeSpan | 30s | Min interval between fail-safe activations per key |
FactorySoftTimeout | TimeSpan | 2s | Return stale data if factory exceeds this; factory continues in background |
FactoryHardTimeout | TimeSpan | 10s | Absolute max factory execution time |
EagerRefreshThreshold | float | 0.8 | Fraction of TTL at which background refresh triggers (0 = disabled) |
BackplaneChannelPrefix | string | "granit:fc" | Redis pub/sub channel prefix for backplane |
Using IFusionCache in your code
Section titled “Using IFusionCache in your code”Inject IFusionCache via constructor injection. FusionCache handles stampede
protection (single factory execution per key), fail-safe, and eager refresh out
of the box.
Read-through caching (GetOrSetAsync)
Section titled “Read-through caching (GetOrSetAsync)”public sealed class ProductService( IFusionCache cache, AppDbContext db){ public async Task<ProductResponse?> GetByIdAsync( Guid id, CancellationToken cancellationToken) { return await cache.GetOrSetAsync<ProductResponse>( $"products:{id}", async (ctx, ct) => { // Factory: called on cache miss or eager refresh return await db.Products .Where(p => p.Id == id) .Select(p => new ProductResponse(p.Id, p.Name, p.Price)) .FirstOrDefaultAsync(ct) .ConfigureAwait(false); }, token: cancellationToken).ConfigureAwait(false); }}Custom entry options
Section titled “Custom entry options”Override the default duration or fail-safe behavior per call:
var options = new FusionCacheEntryOptions{ Duration = TimeSpan.FromMinutes(5), IsFailSafeEnabled = true, FailSafeMaxDuration = TimeSpan.FromHours(1),};
var result = await cache.GetOrSetAsync<UserProfile>( $"profiles:{userId}", async (ctx, ct) => await LoadProfileAsync(userId, ct).ConfigureAwait(false), options, token: cancellationToken).ConfigureAwait(false);Direct set and get
Section titled “Direct set and get”// Store a valueawait cache.SetAsync( $"session:{sessionId}", sessionData, new FusionCacheEntryOptions { Duration = TimeSpan.FromMinutes(30) }, token: cancellationToken).ConfigureAwait(false);
// Retrieve (returns default if missing)var session = await cache.GetOrDefaultAsync<SessionData>( $"session:{sessionId}", token: cancellationToken).ConfigureAwait(false);Key naming conventions
Section titled “Key naming conventions”FusionCache applies the KeyPrefix from CachingOptions automatically as
{KeyPrefix}: before every key you provide. Use colon-separated segments for
your keys:
| KeyPrefix | Your key | Final key in Redis |
|---|---|---|
myapp | products:d4e5f6 | myapp:products:d4e5f6 |
myapp | settings:G:theme | myapp:settings:G:theme |
Use a consistent pattern: {entity}:{identifier} or {module}:{scope}:{identifier}.
Encryption configuration
Section titled “Encryption configuration”AES-256-GCM authenticated encryption protects values stored in Redis (L2 only).
The L1 in-memory cache stores plain .NET objects — same threat model as IMemoryCache.
GCM provides both confidentiality and integrity — tampered cache values are detected
and rejected via the 128-bit authentication tag.
Global encryption
Section titled “Global encryption”Set EncryptValues: true in the Cache section. All values serialized to Redis
are encrypted with AES-256-GCM via EncryptingFusionCacheSerializer.
{ "Cache": { "EncryptValues": true, "Encryption": { "Key": "<base64-aes256-key>" } }}Generate a key:
Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))Per-type control with [CacheEncrypted]
Section titled “Per-type control with [CacheEncrypted]”The [CacheEncrypted] attribute overrides the global EncryptValues flag:
// Always encrypted, even if EncryptValues = false[CacheEncrypted]public sealed class PatientCacheItem{ public string Name { get; init; } = default!; public DateOnly DateOfBirth { get; init; }}
// Never encrypted, even if EncryptValues = true[CacheEncrypted(false)]public sealed class PublicConfigCacheItem{ public string ThemeColor { get; init; } = default!;}
// Follows the global EncryptValues flagpublic sealed class UserSessionCacheItem{ public string Token { get; init; } = default!;}| Attribute | Global EncryptValues | Result |
|---|---|---|
[CacheEncrypted] | any | Encrypted |
[CacheEncrypted(false)] | any | Not encrypted |
| No attribute | true | Encrypted |
| No attribute | false | Not encrypted |
Cache invalidation patterns
Section titled “Cache invalidation patterns”FusionCache offers two invalidation strategies. Prefer ExpireAsync over
RemoveAsync in production — it works with fail-safe to provide resilience.
ExpireAsync (recommended)
Section titled “ExpireAsync (recommended)”Marks the entry as logically expired. If fail-safe is enabled, the stale value remains available as a fallback while the factory re-populates:
// After updating a setting in the databaseawait cache.ExpireAsync( $"settings:G:{settingName}", token: cancellationToken).ConfigureAwait(false);With the Redis backplane, ExpireAsync propagates to all pods — their L1
caches are invalidated in real-time via pub/sub.
RemoveAsync (hard delete)
Section titled “RemoveAsync (hard delete)”Removes the entry entirely. No fail-safe fallback is available after removal:
await cache.RemoveAsync( $"session:{sessionId}", token: cancellationToken).ConfigureAwait(false);Use RemoveAsync only when stale data must never be served (e.g., security
tokens, revoked sessions).
When to use which
Section titled “When to use which”| Scenario | Method | Why |
|---|---|---|
| Setting changed | ExpireAsync | Stale value is acceptable briefly; factory refreshes |
| Permission revoked | ExpireAsync | Fail-safe provides graceful degradation |
| Session terminated | RemoveAsync | Stale session must never be served |
| Cache key rotation | RemoveAsync | Old key format must disappear |
Troubleshooting
Section titled “Troubleshooting”Redis module is loaded but falls back to in-memory
Section titled “Redis module is loaded but falls back to in-memory”Check that Cache:Redis:IsEnabled is true (default). GranitCachingStackExchangeRedisModule
reads this at startup and disables itself when set to false.
Encryption errors after key rotation
Section titled “Encryption errors after key rotation”If you rotate the AES key, existing encrypted entries in Redis become unreadable. Either flush the Redis cache or let entries expire naturally. Fail-safe entries encrypted with the old key will also fail to deserialize.
High latency on cache miss
Section titled “High latency on cache miss”The FactorySoftTimeout (default 2 s) controls how long FusionCache waits
before returning a stale value. If your factory is consistently slow, consider:
- Increasing
FactorySoftTimeoutfor that specific call - Lowering
EagerRefreshThresholdto trigger background refresh earlier - Optimizing the underlying query
Backplane not invalidating across pods
Section titled “Backplane not invalidating across pods”Verify that all pods connect to the same Redis instance and use the same
BackplaneChannelPrefix. Check Redis pub/sub with:
redis-cli SUBSCRIBE "granit:fc:*"Verify
Section titled “Verify”Confirm caching is working by checking response times:
# First call -- cache miss (hits database)time curl -s http://localhost:5000/api/v1/products/1 > /dev/null# -> real 0.120s
# Second call -- cache hittime curl -s http://localhost:5000/api/v1/products/1 > /dev/null# -> real 0.008s
# If using Redis, inspect cached keysredis-cli KEYS "myapp:*"Next steps
Section titled “Next steps”- Configure multi-tenancy — tenant-aware cache keys
- Add an endpoint — use cached data in your API endpoints
- Caching reference — full architecture and service registration details