IronAlpine.Framework.Caching
3.1.1
dotnet add package IronAlpine.Framework.Caching --version 3.1.1
NuGet\Install-Package IronAlpine.Framework.Caching -Version 3.1.1
<PackageReference Include="IronAlpine.Framework.Caching" Version="3.1.1" />
<PackageVersion Include="IronAlpine.Framework.Caching" Version="3.1.1" />
<PackageReference Include="IronAlpine.Framework.Caching" />
paket add IronAlpine.Framework.Caching --version 3.1.1
#r "nuget: IronAlpine.Framework.Caching, 3.1.1"
#:package IronAlpine.Framework.Caching@3.1.1
#addin nuget:?package=IronAlpine.Framework.Caching&version=3.1.1
#tool nuget:?package=IronAlpine.Framework.Caching&version=3.1.1
IronAlpine.Framework.Caching
Redis-backed query caching for the IronAlpine mediator pipeline, a typed cache service, and runtime cache settings (enable/disable a key, override its TTL) stored in the service database.
- Package id:
IronAlpine.Framework.Caching(3.1.1,net10.0) - Depends on: Kernel, Mediator, Data,
StackExchange.Redis,Microsoft.Extensions.Caching.Memory
Installation
<PackageReference Include="IronAlpine.Framework.Caching" Version="3.1.1" />
Registration
using IronAlpine.Caching; // UseCachingBehavior
using IronAlpine.Caching.Extensions; // AddIronAlpineCachingRedis
services.AddIronAlpineData<OrdersDbContext>(configuration, cfg =>
cfg.ConfigureDbContext((sp, options) => options.UseSqlServer(connectionString)));
services.AddIronAlpineCachingRedis<OrdersDbContext>(
configureRedis: redis => redis.InstanceName = "orders",
configureEfCore: efCore => efCore.CacheSettingsSchema = "cache");
services.AddIronAlpineMediator(cfg =>
{
cfg.RegisterServicesFromAssembly(applicationAssembly);
cfg.UseLoggingBehavior();
cfg.UseCachingBehavior(); // before validation and transaction behaviours
// ...
});
AddIronAlpineCachingRedis<TDbContext> registers:
| Service | Lifetime | Notes |
|---|---|---|
IConnectionMultiplexer |
singleton | ConnectionMultiplexer.Connect(...) on first resolve (synchronous) |
ICacheService → RedisCacheService |
singleton | JSON (JsonSerializerDefaults.Web) string values |
ICacheSettingsProvider |
scoped | reads CacheSetting rows, memory-cached for 5 minutes |
ICacheSettingsService |
scoped | CRUD + listing for cache settings (for an admin API) |
| hosted service | singleton | subscribes to the invalidation channel and clears the in-memory settings cache |
IModelContributor |
singleton | adds the CacheSettings table to TDbContext |
IMemoryCache |
- | AddMemoryCache() |
TDbContext must be registered through AddIronAlpineData (the model contributor only runs on contexts configured
by the Data package). Add a migration after registering caching: the CacheSettings table becomes part of the model.
Configuration
{
"IronAlpine": {
"Caching": {
"Redis": {
"ConnectionString": "redis:6379,password=...",
"InstanceName": "orders",
"DefaultDatabase": 0,
"AbortOnConnectFail": false,
"ConnectRetry": 5,
"ConnectTimeoutMilliseconds": 5000,
"EnableTtlJitter": true,
"MaxTtlJitterSeconds": 30,
"CacheSettingsInvalidationChannel": "ironalpine:caching:settings:invalidate",
"EFCore": {
"CacheSettingsTableName": "CacheSettings",
"CacheSettingsSchema": null,
"CacheKeyMaxLength": 256
}
}
}
}
}
| Section | Class | Validation |
|---|---|---|
IronAlpine:Caching:Redis |
RedisOptions |
validated on start: ConnectionString required and parsable, InstanceName and channel non-empty, MaxTtlJitterSeconds > 0 when jitter is on, ConnectRetry >= 0, ConnectTimeoutMilliseconds >= 1000 |
IronAlpine:Caching:Redis:EFCore |
CachingRedisEfCoreOptions |
- (dbo schema is treated as default schema) |
InstanceName is also the Redis client name and the key prefix: every key is stored as {InstanceName}:{key}.
Give each service its own instance name unless services are meant to share entries.
Cacheable queries
using IronAlpine.Caching.Contracts;
using IronAlpine.Caching.Dtos;
[CacheDefinition("Order summary", CacheKey = "order-summary:{OrderId}", DefaultExpirationSeconds = 600,
Description = "Order header with totals")]
public sealed record GetOrderSummaryQuery(Guid OrderId) : IRequest<Result<OrderSummaryDto>>, ICacheableQuery
{
public string CacheKey => $"order-summary:{OrderId}";
public TimeSpan? Expiration => TimeSpan.FromMinutes(10);
}
CachingBehavior<TRequest, TResponse> applies when TRequest implements ICacheableQuery and TResponse is
Result<T> (or derives from it). For each request it:
- resolves the override for
request.CacheKeyfromICacheSettingsProvider(see override resolution); an override withIsEnabled = falsebypasses the cache; - reads
{InstanceName}:{CacheKey}from Redis; a hit returnsResult.Success(payload)without calling the handler; - on a miss, takes an optional distributed lock (
IDistributedLock, if registered,cache:{key}, 3 s timeout) and a per-key in-process semaphore (reference counted: removed as soon as no request holds or waits for the key), re-reads the cache, then calls the handler; - stores the value of a successful result whose value is not
null; failures are never cached.
Value-type payloads (Result<int>, Result<bool>, Result<Guid>, structs) are read as Nullable<T> so a miss
(null) is distinguished from a cached 0 / false / Guid.Empty. A custom ICacheService must therefore return
default (null for T?) on a miss and accept GetAsync<T?> for values written with SetAsync<T>.
TTL precedence: override ExpirationSeconds → request.Expiration → no expiry (null means the key never
expires). With EnableTtlJitter a random 0..MaxTtlJitterSeconds seconds is added to every TTL.
Redis read/write errors and lock errors are logged as warnings and the handler result is returned (the cache never breaks a request).
Invalidation
public sealed class ShipOrderCommandHandler(IRepository<Order> orders, IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<ShipOrderCommand, Result>
{
public async Task<Result> Handle(ShipOrderCommand request, CancellationToken cancellationToken)
{
// ... mutate and save
await unitOfWork.SaveChangesAsync(cancellationToken);
await cache.RemoveAsync($"order-summary:{request.OrderId}", cancellationToken);
return Result.Success();
}
}
ICacheService: GetAsync<T>, SetAsync<T>(key, value, expiry), RemoveAsync, ExistsAsync. Keys are exact;
there is no prefix or wildcard removal (RemoveAsync("orders:*") deletes a key literally named orders:*).
Design keys so that every write knows the exact keys it invalidates, or keep TTLs short.
Cache settings (runtime overrides)
ICacheSettingsService backs an admin API:
| Method | Result |
|---|---|
GetDefinitionsAsync |
all known cache definitions (see below), cached in memory for 10 minutes |
GetPagedAsync(CacheSettingsFilter) |
definitions merged with overrides; filter by key/display name and IsEnabled, sort by cacheKey, displayName, isEnabled, defaultExpirationSeconds, overrideExpirationSeconds |
GetAllAsync, GetByKeyAsync(cacheKey) |
same data, unpaged / single |
UpsertAsync(CacheSettingUpsertDto(CacheKey, ExpirationSeconds, IsEnabled)) |
saves the row, clears local caches and publishes on the invalidation channel |
DeleteAsync(cacheKey) |
removes the override |
[HttpPut]
public async Task<IActionResult> Upsert(CacheSettingUpsertDto request, ICacheSettingsService settings, CancellationToken ct)
=> (await settings.UpsertAsync(request, ct)).IsSuccess ? Ok() : BadRequest();
CacheSettingsQueryParams (Page, Size, SortBy, SortDirection, CacheKey, IsEnabled) binds from a query
string and converts with ToFilter().
Definitions are discovered by reflection: every non-abstract ICacheableQuery class contributes
CacheDefinitionAttribute.CacheKey when set, otherwise the CacheKey of an instance created through its
parameterless constructor. Scanned assemblies are CachingRedisEfCoreOptions.DefinitionAssemblies plus the loaded
assemblies accepted by CachingRedisEfCoreOptions.DefinitionAssemblyFilter (default: name ends with .Application).
Both are code-only options:
services.AddIronAlpineCachingRedis<OrdersDbContext>(configureEfCore: efCore =>
{
efCore.DefinitionAssemblies.Add(typeof(GetOrderSummaryQuery).Assembly);
efCore.DefinitionAssemblyFilter = _ => false; // only the explicit assemblies
});
Override resolution
For a concrete key such as order-summary:42 the behaviour uses the first match of:
- an override saved under the exact key (
order-summary:42); - an override saved under the request type's
CacheDefinitionAttribute.CacheKey; - an override whose key is a template (
order-summary:{OrderId}) that matches the concrete key. Each{name}placeholder matches one or more characters; the rest is compared literally, case-insensitively.
Up to 3.0.4 only the exact key was used, so overrides saved against a template never applied.
Metrics
Since 3.1.0 the caching behavior counts cache.hit, cache.miss (the handler ran) and cache.bypassed (a runtime
override disabled caching) on the meter IronAlpine.Caching. The only tag is cache.definition: the
[CacheDefinition(CacheKey = ...)] key, or the query type name when the attribute is missing. The concrete key is
never a tag (it contains ids). A request that finds the value after waiting for the lock counts as a hit.
AddIronAlpineTelemetry (Observability) listens to this meter; see its README for PromQL.
Extension points
IDistributedLock(TryAcquireAsync(resourceId, timeout, ct)returning anIAsyncDisposablelease ornull): register an implementation to serialise cache misses across instances. None is shipped.ICacheService: replace with another store (services.AddSingleton<ICacheService, MyCache>()beforeAddIronAlpineCachingRedis, which usesTryAdd).ICacheSettingsProvider: replace to read overrides from configuration or another store.
Pitfalls
- Up to 3.0.4 value-type payloads (
Result<int>,Result<bool>,Result<Guid>) were broken: a miss returneddefault(T)and was treated as a hit. Fixed in 3.0.5 (see above). - Payloads round-trip through
System.Text.Json. Types without a usable constructor or with private setters (for example smart enumerations or domain entities) do not deserialize correctly. Cache flat DTOs. Expiration => nulland no override means the entry never expires.- Key spaces should still be bounded in Redis (for example avoid keys containing free text search terms); the in-process per-key semaphores are released when unused (since 3.0.5).
- Results are shared by everyone who sends the same key. Include the user, tenant or permission scope in the key for user-specific data.
GetAsync/SetAsyncignore the cancellation token (StackExchange.Redis does not take one).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- IronAlpine.Framework.Data (>= 3.1.1)
- IronAlpine.Framework.Kernel (>= 3.1.1)
- IronAlpine.Framework.Mediator (>= 3.1.1)
- Microsoft.EntityFrameworkCore (>= 10.0.10)
- Microsoft.Extensions.Caching.Memory (>= 10.0.10)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.10)
- StackExchange.Redis (>= 2.8.37)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.1.1 | 173 | 10/21/2026 |
| 3.0.9 | 211 | 10/15/2026 |
| 3.0.8 | 114 | 10/13/2026 |
| 3.0.7 | 95 | 10/13/2026 |
| 3.0.6 | 105 | 10/9/2026 |
| 3.0.5 | 180 | 10/5/2026 |
| 3.0.4 | 192 | 10/2/2026 |
| 3.0.3 | 165 | 9/30/2026 |
| 3.0.2 | 956 | 7/22/2026 |
| 3.0.1 | 128 | 7/22/2026 |
| 3.0.0 | 320 | 7/20/2026 |
| 2.0.3 | 119 | 7/20/2026 |
| 2.0.2 | 147 | 7/13/2026 |
| 2.0.1 | 149 | 7/13/2026 |
| 2.0.0 | 144 | 7/13/2026 |
| 2.0.0-preview.2 | 77 | 7/13/2026 |
| 2.0.0-preview.1 | 87 | 7/6/2026 |
IronAlpine framework release. See the package README and repository documentation for details.