The Caching block wires Microsoft.Extensions.Caching.Hybrid for the whole platform. L1 is in-process memory; L2 is Valkey (a Redis-compatible, BSD-licensed Redis fork) when CachingOptions:Redis is set, otherwise the in-process DistributedMemoryCache. The block decorates the underlying HybridCache with ObservableHybridCache to emit OpenTelemetry metrics + activities, and it shares the IConnectionMultiplexer with DataProtection key persistence so the whole host opens one Valkey connection pool, not one per feature.
What it ships
Extension
AddHeroCaching(services, configuration)— wires HybridCache + L2 + the OTel decorator. ReadsCachingOptions.
Decorator
ObservableHybridCache(inner)— wraps the frameworkHybridCacheto emit:fsh.cache.hits/fsh.cache.misses(OTel counters; L1 and L2 hits are both counted as “hit” — HybridCache doesn’t surface which layer served the read)fsh.cache.invalidations(OTel counter;RemoveAsync+RemoveByTagAsync)fsh.cache.factory.duration(OTel histogram, ms; recorded only on a miss)- Activities under
ActivitySource("FSH.Caching")withcache.system,cache.key,cache.hit(orcache.tag) tags.
Options
CachingOptions:Redis— connection string; empty falls back to in-memoryDistributedMemoryCacheEnableSsl— TLS toggle (defaults to inference from the connection string)DefaultExpiration— TimeSpan; default 1 hour, applies to L1 + L2DefaultLocalCacheExpiration— TimeSpan; default 2 minutes; bounds cross-node staleness after RemoveByTag on peersMaximumKeyLength— default 1024 chars; longer keys are rejectedMaximumPayloadBytes— default 1 MB; larger payloads are silently skipped
Telemetry helper
CachingTelemetry— theActivitySource+ meter the decorator emits (both namedFSH.Caching, exposed asActivitySourceName/MeterNameconstants). Wire them into the OTel pipeline viametrics.AddMeter(...)/tracing.AddSource(...).
How modules consume it
Inject HybridCache and call GetOrCreateAsync:
public sealed class GetTenantConfigQueryHandler(HybridCache cache, ITenantConfigRepo repo) : IQueryHandler<GetTenantConfigQuery, TenantConfig>{ public ValueTask<TenantConfig> Handle(GetTenantConfigQuery q, CancellationToken ct) => cache.GetOrCreateAsync( $"tenant-config:{q.TenantId}", async (innerCt) => await repo.LoadAsync(q.TenantId, innerCt).ConfigureAwait(false), cancellationToken: ct);}For tag-based invalidation:
await cache.GetOrCreateAsync(key, factory, tags: ["tenant:" + tenantId.ToString()], cancellationToken: ct);
// later, on tenant config changeawait cache.RemoveByTagAsync("tenant:" + tenantId.ToString(), ct).ConfigureAwait(false);Identity uses HybridCache for per-user permission sets (UserPermissionService, warmed by the RolePermissionSyncer) and impersonation grants (ImpersonationGrantService); Multitenancy caches tenant themes (TenantThemeService) and fronts the tenant EF store with Finbuckle’s DistributedCacheStore, which rides on the same IDistributedCache this block registers.
Configuration
{ "CachingOptions": { "Redis": "localhost:6379", "DefaultExpiration": "01:00:00", "DefaultLocalCacheExpiration": "00:02:00", "MaximumKeyLength": 1024, "MaximumPayloadBytes": 1048576 }}When Redis is empty the block falls back to in-memory L2 (DistributedMemoryCache) — fine for dev/test, not for multi-instance production. Always set Valkey (or another distributed cache) for production.
How to extend
Use the shared multiplexer for your own Valkey work
The block exposes the IConnectionMultiplexer through DI; resolve it and reuse instead of opening a second pool:
public sealed class MyRedisService(IConnectionMultiplexer redis){ public async Task<string?> ReadAsync(string key) { var db = redis.GetDatabase(); return await db.StringGetAsync(key); }}Add another decorator
Decorate the registered HybridCache with another wrapper to add e.g. circuit-breaker logic. Pull the existing descriptor out of the service collection, register your decorator, and chain — ObservableHybridCache does this exact dance for OTel.
Pre-warm on startup
Resolve HybridCache from a hosted service and GetOrCreateAsync your hot keys before traffic arrives. The Identity module’s RolePermissionSyncHostedService is the template.
Gotchas
- L1 has no backplane. After
RemoveByTagon one instance, peer instances’ L1 copies aren’t invalidated. TheDefaultLocalCacheExpiration(2 min default) bounds the staleness window — don’t set it too high if you rely on fast invalidation. - Payload size cap is silent. Entries larger than
MaximumPayloadBytesare skipped (with a warning log), not stored. If your hot-path cache value is suddenly 1.5 MB, lookup will miss every time. Log-search for “payload too large” warnings before assuming the cache is broken. - One multiplexer, two consumers. Caching and DataProtection key persistence share the same
IConnectionMultiplexer. The SignalR backplane (AddHeroRealtime) reads the sameCachingOptions:Redisstring but opens its own connection — so a multi-instance host with realtime enabled holds two Valkey connections, not one. DistributedMemoryCacheis per-process. Two instances of the API can’t see each other’s cache. This is intentional for test fixtures; if you accidentally ship it to multi-instance prod, multi-tenant cache invalidation breaks. Always checkCachingOptions:Redisis set in production config.
Critical files
src/BuildingBlocks/Caching/Extensions.cssrc/BuildingBlocks/Caching/ObservableHybridCache.cssrc/BuildingBlocks/Caching/CachingOptions.cssrc/BuildingBlocks/Caching/Telemetry/CachingTelemetry.cs
Related
- Web —
FshPlatformOptions.EnableCachingtoggle. - Multitenancy module — uses HybridCache as the
DistributedCacheStore. - Chat module — typing-indicator throttle uses the cache.