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 so the whole host opens one Valkey connection pool, not three.
What it ships
Extension
AddHeroCaching(services, configuration)— wires HybridCache + L2 + the OTel decorator. ReadsCachingOptions.
Decorator
ObservableHybridCache(inner)— wraps the frameworkHybridCacheto emit:cache.hits/cache.misses(OTel counter)cache.duration(OTel histogram, ms)cache.payload.bytes(OTel histogram)- Activities under
ActivitySource("Hero.Caching")withcache.key,cache.hit,cache.payload.bytestags.
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+ meters the decorator emits. Use it directly if you wrap further.
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 role-permission lookups (the RolePermissionSyncer warms the cache); Multitenancy uses it as the DistributedCacheStore in front of the tenant EF store; Webhooks uses it for typing-indicator throttle keys; Chat uses it for the typing throttle.
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, three consumers. Caching, DataProtection, and SignalR (when realtime is enabled) share the same
IConnectionMultiplexer. Bumping connection pool size affects all three. 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.