Skip to content

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.

  • A working Granit application
  • Redis server (for production setups)
  1. Install the package

    Terminal window
    dotnet add package Granit.Caching
  2. Register the module

    [DependsOn(typeof(GranitCachingModule))]
    public sealed class AppModule : GranitModule { }

    No Redis, no additional configuration. GranitCachingModule registers IFusionCache with an L1 in-memory cache backed by MemoryDistributedCache.

  3. Configure (optional)

    appsettings.Development.json
    {
    "Cache": {
    "KeyPrefix": "myapp"
    }
    }
  1. Install both packages

    Terminal window
    dotnet add package Granit.Caching
    dotnet add package Granit.Caching.StackExchangeRedis
  2. Register both modules

    [DependsOn(
    typeof(GranitCachingModule),
    typeof(GranitCachingStackExchangeRedisModule))]
    public sealed class AppModule : GranitModule { }

    GranitCachingStackExchangeRedisModule depends on GranitCachingModule — if you only declare GranitCachingStackExchangeRedisModule, the base module is pulled in automatically.

  3. 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
    }
    }
    }

Three configuration sections control caching behavior.

PropertyTypeDefaultDescription
KeyPrefixstring"dd"Prefix for all cache keys. Format: {KeyPrefix}:{userKey}
DefaultAbsoluteExpirationRelativeToNowTimeSpan?1hDefault entry duration (TTL)
DefaultSlidingExpirationTimeSpan?20minSliding expiration (reset on each access)
EncryptValuesboolfalseEnable AES-256 encryption globally on L2
JsonOptionsJsonSerializerOptions?nullCustom JSON serialization options
PropertyTypeDefaultDescription
IsEnabledbooltrueSet false to disable Redis without changing modules
Configurationstring"localhost:6379"StackExchange.Redis connection string
InstanceNamestring"dd:"Redis key prefix for multi-app isolation

Cache:FusionCache section (FusionCachingOptions)

Section titled “Cache:FusionCache section (FusionCachingOptions)”
PropertyTypeDefaultDescription
FailSafeIsEnabledbooltrueServe expired entries as fallback when the factory fails
FailSafeMaxDurationTimeSpan2hMax duration to keep fail-safe entries available
FailSafeThrottleDurationTimeSpan30sMin interval between fail-safe activations per key
FactorySoftTimeoutTimeSpan2sReturn stale data if factory exceeds this; factory continues in background
FactoryHardTimeoutTimeSpan10sAbsolute max factory execution time
EagerRefreshThresholdfloat0.8Fraction of TTL at which background refresh triggers (0 = disabled)
BackplaneChannelPrefixstring"granit:fc"Redis pub/sub channel prefix for backplane

Inject IFusionCache via constructor injection. FusionCache handles stampede protection (single factory execution per key), fail-safe, and eager refresh out of the box.

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);
}
}

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);
// Store a value
await 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);

FusionCache applies the KeyPrefix from CachingOptions automatically as {KeyPrefix}: before every key you provide. Use colon-separated segments for your keys:

KeyPrefixYour keyFinal key in Redis
myappproducts:d4e5f6myapp:products:d4e5f6
myappsettings:G:thememyapp:settings:G:theme

Use a consistent pattern: {entity}:{identifier} or {module}:{scope}:{identifier}.

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.

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))

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 flag
public sealed class UserSessionCacheItem
{
public string Token { get; init; } = default!;
}
AttributeGlobal EncryptValuesResult
[CacheEncrypted]anyEncrypted
[CacheEncrypted(false)]anyNot encrypted
No attributetrueEncrypted
No attributefalseNot encrypted

FusionCache offers two invalidation strategies. Prefer ExpireAsync over RemoveAsync in production — it works with fail-safe to provide resilience.

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 database
await 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.

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).

ScenarioMethodWhy
Setting changedExpireAsyncStale value is acceptable briefly; factory refreshes
Permission revokedExpireAsyncFail-safe provides graceful degradation
Session terminatedRemoveAsyncStale session must never be served
Cache key rotationRemoveAsyncOld key format must disappear

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.

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.

The FactorySoftTimeout (default 2 s) controls how long FusionCache waits before returning a stale value. If your factory is consistently slow, consider:

  • Increasing FactorySoftTimeout for that specific call
  • Lowering EagerRefreshThreshold to trigger background refresh earlier
  • Optimizing the underlying query

Verify that all pods connect to the same Redis instance and use the same BackplaneChannelPrefix. Check Redis pub/sub with:

Terminal window
redis-cli SUBSCRIBE "granit:fc:*"

Confirm caching is working by checking response times:

Terminal window
# First call -- cache miss (hits database)
time curl -s http://localhost:5000/api/v1/products/1 > /dev/null
# -> real 0.120s
# Second call -- cache hit
time curl -s http://localhost:5000/api/v1/products/1 > /dev/null
# -> real 0.008s
# If using Redis, inspect cached keys
redis-cli KEYS "myapp:*"