Caching
Cache a query's successes per Actor, tenant and culture over HybridCache, and evict them by the cache tags a command declares, once its Operation commits.
PouryaKit.Caching lets a query opt in to having its successes cached. The query implements ICacheableQuery and names its key, its duration and the cache tags of the resources its answer reads. The Kit adds the query's type, the tenant, the Actor and the culture to the key, so an entry answers only the query and the caller that stored it. A command that changes a cached resource implements IInvalidatesCache and declares the tags it invalidates; the Kit evicts them once the command's Operation commits, and a rolled-back Operation evicts nothing.
Entries live over HybridCache, in each Host's memory. A Host that registers a distributed cache, such as Redis, shares them between the application's Hosts; the Kit references no distributed cache itself.
An eviction is certain only in the Host that committed
Another Host may answer from its own in-memory copy until the entry expires, so choose the duration with that in mind. Only a command evicts: data changed without one, such as by an Integration Event Handler, needs a short duration or no caching.
When to use it
- A query is read far more often than what it reads changes.
- A command changes something a cached query answers, and the next read must not see the old answer.
- Several Hosts of the application should share cached answers through Redis.
- You test what a cached query answers.
How it works
Register the cache in the Host
AddPouryaKitCaching() fills the dispatcher's query cache. A Host whose queries include a cacheable one and that never calls it fails when it is built, so a cacheable query never runs uncached without a word.
Declare the key, duration and tags on the query
CacheKey names only what the query asks; CacheTags name the resources its answer reads, and a tag spans every Actor and tenant. Only an Operation's outermost query uses the cache, after its authorization and validation have run.
Declare what a command invalidates
InvalidatedCacheTags is read from the command alone, before its handler runs. A command that finds what it changes by a criterion declares a tag every entry it may change carries. Feature code never evicts.
In the Proving Sample
Catalog's GetProduct is cacheable, tagged with the product it reads and with a tag every product read carries:
internal sealed record GetProductQuery(Guid ProductId) : Query<ProductDto>, ICacheableQuery
{
/// <summary>The tag of every cached read of one product, which a command that changes the product declares.</summary>
public static string ProductTag(Guid productId) => $"product:{productId}";
/// <summary>The tag every cached product read carries, for a command that cannot name the products it changes.</summary>
public const string ProductsTag = "products";
public string CacheKey => ProductId.ToString();
public TimeSpan CacheDuration => TimeSpan.FromMinutes(5);
public IReadOnlyCollection<string> CacheTags => [ProductTag(ProductId), ProductsTag];
}Renaming a product invalidates that product's tag, and the entry is evicted once the rename commits:
internal sealed record RenameProductCommand(Guid ProductId, string Name)
: Command,
IInvalidatesCache
{
public IReadOnlyCollection<string> InvalidatedCacheTags =>
[GetProductQuery.ProductTag(ProductId)];
}Guarantees and failures
| You want to know | Read |
|---|---|
| What the registration fills, and the build failure without it | Registering |
| What the Kit adds to the key, and which queries use the cache | A cacheable query |
| When tags are read and evicted | A command that invalidates |
| What is never stored, and what a read across a commit does | What the Kit guarantees |
| What holds only per Host | Limits |
Next steps
Commands, queries and validation
The pipeline the cache sits in, after the checks.
Aspire resources
Redis for a Host that shares its cache, in the local AppHost.
Reference
PouryaKit.Caching: a query opts in to having its successes cached, per Actor and tenant, over HybridCache, and a command declares the cache tags it invalidates, evicted once its Operation commits. Namespace PouryaKit.Caching. It references PouryaKit.Cqrs and Microsoft.Extensions.Caching.Hybrid only. Redis, or any other distributed cache, is the application's own IDistributedCache registration; the Kit never references one.
Registering
On the Host:
using PouryaKit.Caching;
builder.Services.AddPouryaKitCaching();
// Optional: share entries between the application's Hosts. Microsoft.Extensions.Caching.StackExchangeRedis.
if (builder.Configuration.GetConnectionString("cache") is { } redis)
{
builder.Services.AddStackExchangeRedisCache(options => options.Configuration = redis);
}AddPouryaKitCachingfills the dispatcher's query cache slot and registersHybridCacheunless one is registered. A second call adds nothing. It registers no pipeline step an application could add to, and an application never implementsIQueryCacheitself: a structure test rejects one (testing.md).- A Host whose registered queries include a cacheable one calls it, or fails when it is built, before anything runs, naming the call (
The Host registers no query cache, so every cacheable query would run uncached: … Call AddPouryaKitCaching() on the Host's services.): a cacheable query never runs uncached without a word. It is a composition check ofAddPouryaKitCompositionChecks, whichAddPouryaKitModulescalls (modules.md).HybridCacheneeds no distributed cache, so the call is the whole fix. A Host with no cacheable query runs every query without one. - Without a distributed cache, entries live in each Host's memory. With one,
HybridCachewrites each entry there too, and another Host reads it.
A cacheable query
A Module references PouryaKit.Caching for the contracts and declares the key, the duration and the tags on the query, as the Proving Sample's GetProduct does:
using PouryaKit.Caching;
using PouryaKit.Cqrs;
internal sealed record GetProductQuery(Guid ProductId) : Query<ProductDto>, ICacheableQuery
{
public static string ProductTag(Guid productId) => $"product:{productId}";
public const string ProductsTag = "products";
public string CacheKey => ProductId.ToString();
public TimeSpan CacheDuration => TimeSpan.FromMinutes(5);
public IReadOnlyCollection<string> CacheTags => [ProductTag(ProductId), ProductsTag];
}CacheKeynames only what the query asks. The Kit adds the query's type, as its full name and its assembly's simple name, the Operation's tenant (an Operation without one asOperationContext.NoTenant), its Actor, by kind and id, and the current UI culture's name, so an entry answers only the query, the Actor, the tenant and the culture that stored it: a localized answer (localization.md) is kept per culture, and a Host without localization runs in one. Renaming the query's type, or the assembly that holds it, starts new entries.CacheTagsname the resources the answer reads. A tag spans Actors and tenants: one eviction reaches every Actor's entry.- The response is stored as JSON in the Kit's representation (
UsePouryaKitJson), so it must round-trip through it; every answer, the first included, is read back from that JSON. - The handler is written as for any query, and runs inside its Operation as usual: a
Continue()it calls is a trusted continuation. - Only an Operation's outermost query uses the cache. Sent from another query's handler, an authorizer, a validator, a command's handler or a Domain Event Handler, the same query runs and answers as any query, but is neither read from nor stored in the cache; so is a query in an Operation that has already failed. Send a query you want cached from the Gateway or the endpoint, not from inside another step.
- Once started, the handler runs to completion: other callers of the same query may be waiting on it, so it receives no cancellation, and every caller, the one that started it included, waits for it. A caller's cancellation counts only until the run starts or is joined. Keep a cacheable query's handler short.
A command that invalidates
Every command that changes a cached resource declares its tag:
internal sealed record RenameProductCommand(Guid ProductId, string Name) : Command, IInvalidatesCache
{
public IReadOnlyCollection<string> InvalidatedCacheTags => [GetProductQuery.ProductTag(ProductId)];
}
// Removes by name, so it cannot name the products' ids before it runs.
internal sealed record RemoveProductsCommand(IReadOnlyList<string> Names) : Command<int>, IInvalidatesCache
{
public IReadOnlyCollection<string> InvalidatedCacheTags => [GetProductQuery.ProductsTag];
}- The tags are read from the command alone, after its checks and before its handler runs. A command that finds what it changes by a criterion declares a tag every entry it may change carries, as
RemoveProductsdeclaresproducts. - Feature code never evicts. The dispatcher collects the tags of the outermost command and of every command nested in its Unit of Work, a Domain Event Handler's included, and evicts them once the Unit of Work has committed.
What the Kit guarantees
- One
HybridCache.GetOrCreateAsyncper cacheable query. Concurrent misses of one key in one Host share one handler run,HybridCache's own single flight. - Never a failed Result. A failure is returned to every caller that shared the run and stored nowhere, in memory or in the distributed cache: the next query runs again.
- Nothing a query changed. The cache serializes a success, and only then runs the Operation's read-only check, before it stores the answer: a handler that changes tracked state, or an answer whose serialization does, such as a deferred enumerable, fails the check. Every caller that shared the run receives the
InvalidOperationException, and nothing is stored. - After the checks. The authorization hook and validation run before the cache is read, so a stored answer never skips them.
- Only the outermost query of an Operation that has not failed. A query nested in a command, a check or another query may read a change they made before it started, which its own read-only check takes as its starting point, so it neither reads nor stores an entry. That covers a command's handler, a Domain Event Handler, any command's or query's authorizers and validators, the outermost command's included, and another query's handler. A query in an Operation that has already failed, such as one sent after a refused command in the same scope, runs uncached too, since the Operation may still track what was rolled back.
- Eviction after the outermost commit only. A rolled-back Operation evicts nothing, so a valid entry survives a failed command. A command that rolls back after a nested query leaves no entry from it.
- A read in flight across a commit stores nothing. A run whose handler was still running when a commit in this Host evicted one of its tags returns its answer to every caller that shared it and stores none, so the next read runs the handler again. A command that declares
HybridCache's wildcard tag*, which evicts every entry, reaches every run in flight; a run on other tags only is stored as usual.HybridCachealone would keep that answer, read before the commit, until it expires: it stamps an entry again when one of its tags was evicted while the handler ran. - A failed eviction cannot fail a committed Operation. When the distributed cache refuses it, the Kit logs
EvictionFailedat Error level and returns the command's success; this Host's own copy is still evicted.
Limits
- Single flight is per Host. Two Hosts that miss the same key both run the handler.
- An eviction is certain only in the Host whose Operation committed. Another Host may return its own in-memory copy until it expires; choose
CacheDurationwith that in mind. - On another Host, a query that read before a command committed may store the answer it read, which that Host's
HybridCachekeeps until it expires: only the committing Host's own runs in flight see its evictions. In the committing Host, an eviction that lands in the microseconds between the Kit's check, made once the handler's read has ended, andHybridCache's own store is not seen either. - A caller that joins a run in flight after a commit that evicted one of the run's tags receives that run's answer, read before the commit, for that one call; the run stores nothing.
- Nested reads are never cached: a cacheable query an authorizer sends, such as a permission lookup, runs its handler every time.
- The Kit caches query answers only. An Integration Event Handler or anything else that changes a cached resource without a command declares nothing; such data needs a short duration or no caching.
Testing
The Proving Sample's tests show the patterns: change a row with plain SQL behind the cache's back, so a stored answer (the old value) differs from a fresh read (the new one); count a handler's runs with a probe; and for Redis, start a Testcontainers.Redis container, compose two Hosts with its connection string, and wait until the first Host's entry is in Redis before the second reads it, since HybridCache answers before its distributed write completes.
Project templates
Start a new application with dotnet new as a monolith, a modular monolith or a service of one Module, generated from the Proving Sample's business code.
File storage
Store, download and delete files under a key, and give a time-limited link to one, through one contract over S3, MinIO or Azure Blob Storage.