Caching
Declarative handler result caching with [Cacheable] and tag-based invalidation with [CacheInvalidate], backed by HybridCache.
Elarion can cache handler results declaratively. Mark a read handler with [Cacheable] and the
generator wraps it in a cache decorator; mark a write handler with [CacheInvalidate] and it clears
the matching entries. Caching is opt-in per handler and integrates with the same
decorator pipeline as everything else.
Only successful Result<T> responses are cached. Validation and domain failures are returned
to the caller but never stored, so a transient failure cannot become sticky.
Caching a read handler
using Elarion.Abstractions;
using Elarion.Abstractions.Caching;
using Microsoft.EntityFrameworkCore;
[Cacheable("clients", DurationSeconds = 120)]
[Handler("clients.get")]
public sealed class GetClient(AppDbContext db)
: IHandler<GetClient.Query, Result<GetClient.Response>> {
public sealed record Query(Guid Id);
public sealed record Response(Guid Id, string Name);
public async ValueTask<Result<Response>> HandleAsync(Query query, CancellationToken ct) {
var client = await db.Clients
.Where(c => c.Id == query.Id)
.Select(c => new Response(c.Id, c.Name))
.FirstOrDefaultAsync(ct);
return client is null
? AppError.NotFound($"Client {query.Id} was not found.")
: client;
}
}The generator emits an IHandlerCachePolicy<GetClient.Query> from the attribute metadata and inserts
a CacheDecorator<,> into the handler's pipeline. The decorator computes a key from the request,
looks it up, and only runs the handler on a miss.
[Cacheable] properties
| Property | Default | Meaning |
|---|---|---|
tags (constructor) | none | Logical cache tags for grouping and invalidation. |
DurationSeconds | 60 | Entry lifetime, applied to both the distributed and local cache layers. |
Scope | CurrentUser | Whether keys and tags are isolated per user or shared globally. |
KeyProperties | all public request properties | Which request properties contribute to the key. An unknown name is reported at build time (ELCACHE007). |
Use KeyProperties when only some request fields affect the response:
[Cacheable("reports", DurationSeconds = 300, KeyProperties = new[] { "Year", "Month" })]Every property that participates in the key must have a stable, injective string formatting: a
primitive, string, char, bool, Guid, an enum, DateTime/DateTimeOffset/DateOnly/TimeOnly/
TimeSpan, decimal, or a Nullable of those. A property of any other type (a collection, array, or
custom object) would fall back to object.ToString() — colliding every value into one key and risking a
cross-request cache leak — so the generator rejects it at build time
(ELCACHE006). Narrow the key with KeyProperties to exclude such a
property.
Cache scope
HandlerCacheScope controls isolation:
| Scope | Behavior |
|---|---|
CurrentUser (default) | Entries and tags are isolated by the authenticated user. Requires an ICurrentUser with a user id; the id is hashed before it enters the physical cache key. |
Global | Entries are shared across all users. Use only for data that is not user-specific and carries no authorization context. |
CurrentUser is the default precisely so that one user's cached response is never served to
another. Switch to Global deliberately, only for non-personalized data.
Invalidating with tags
A command handler clears cached entries by tag with [CacheInvalidate]. Invalidation is tag-based,
so the writer does not need to know the concrete keys produced by read handlers:
[CacheInvalidate("clients")]
[Handler("clients.update")]
public sealed class UpdateClient(AppDbContext db)
: IHandler<UpdateClient.Command, Result<UpdateClient.Response>> {
public sealed record Command(Guid Id, string Name);
public sealed record Response(Guid Id);
public async ValueTask<Result<Response>> HandleAsync(Command command, CancellationToken ct) {
var client = await db.Clients.FirstOrDefaultAsync(c => c.Id == command.Id, ct);
if (client is null) {
return AppError.NotFound($"Client {command.Id} was not found.");
}
client.Name = command.Name;
await db.SaveChangesAsync(ct);
// The decorator invalidates the "clients" tag on success.
return new Response(client.Id);
}
}[CacheInvalidate] takes the same tags and Scope as [Cacheable]. The scopes compose precisely:
- A
Globalinvalidation (the default) evicts the globally shared entries and every user'sCurrentUser-scoped entries for the listed tags. User-scoped entries are deliberately also stamped with the global tag namespace so the default[Cacheable](CurrentUser) / default[CacheInvalidate](Global) pairing works out of the box. - A
CurrentUserinvalidation clears only the invoking user's entries for the listed tags — never another user's, and never the globally shared entries.
[CacheInvalidate]'s Scope defaults to Global — unlike [Cacheable], whose default is
CurrentUser. Invalidation is a mutation reacting to a state change, and the caller performing it is
usually not the user whose cached read must be evicted (an admin editing another user's record). A
CurrentUser-scoped invalidation would clear only the mutator's own tag and leave every affected user
permanently stale, so the safe default is over-invalidation (Global — it evicts the tag across all
users). Set Scope = HandlerCacheScope.CurrentUser explicitly only for a genuinely per-user cache the
mutating caller also owns.
Registering the cache runtime
The attributes and generated policies are runtime-neutral. The host chooses the implementation. The
default is backed by HybridCache:
// dotnet add package Elarion.Caching
builder.Services.AddElarionHandlerCaching();Elarion.Caching is an opt-in sibling package — referencing it is what pulls
Microsoft.Extensions.Caching.Hybrid, so the core stays dependency-light
(ADR-0017).
This registers HybridCache and the default IHandlerCache (HybridHandlerCache). There are three
ways to change the backing store, from least to most invasive:
- Add an L2 distributed cache so entries survive restarts and are shared across instances — see
Adding an L2 distributed cache below. The recommended default for
most applications is a PostgreSQL
UNLOGGEDtable; Redis, SQL Server, and others work too. - Swap the
HybridCacheimplementation itself.HybridCacheis the abstraction, andHybridHandlerCachesimply resolves whateverHybridCacheis in the container. Register your ownHybridCachebeforeAddElarionHandlerCaching()— itsAddHybridCache()call usesTryAddSingleton, so it adds the default only when one is not already registered, and your implementation wins. - Replace
IHandlerCacheentirely when you want to bypassHybridCacheand own the whole caching contract.
Adding an L2 distributed cache
HybridCache is two-tier. The L1 is an in-process MemoryCache that absorbs the hot path and is
lost on restart; the L2 is an optional IDistributedCache that HybridCache auto-discovers from DI
and uses for cross-instance coherence, warm-restart survival, and stampede coordination. With no L2
registered, caching is L1-only — fine for a single instance, but each instance keeps its own copy and a
restart starts cold.
For most applications the recommended L2 is a PostgreSQL UNLOGGED table — reuse the database you
already run instead of standing up a separate Redis tier. Elarion.Caching.PostgreSql wires it in one
call:
// dotnet add package Elarion.Caching.PostgreSql
using Elarion.Caching.PostgreSql;
builder.Services.AddElarionPostgreSqlHandlerCaching(
builder.Configuration.GetConnectionString("CacheDb")!);That registers the official Microsoft.Extensions.Caching.Postgres distributed cache as the L2 — an
auto-created UNLOGGED table named elarion_cache in the public schema — and calls
AddElarionHandlerCaching() for you, so HybridCache picks the table up automatically. The optional
configure delegate overrides any default (it runs last, so it always wins):
builder.Services.AddElarionPostgreSqlHandlerCaching(
builder.Configuration.GetConnectionString("CacheDb")!,
options => {
options.SchemaName = "cache";
options.TableName = "handler_cache";
});An UNLOGGED table skips the write-ahead log, so cache writes are cheap and produce no WAL or
replication traffic (this is the package's UseWAL = false, which Elarion defaults on). The tradeoff —
the table is truncated on a crash or unclean shutdown and is not present on physical standby
replicas — is exactly right for a cache: its contents are always reconstructible from the source of
truth, so the worst case is a cold repopulate, never data loss.
Why this fits most apps: HybridCache's in-process L1 carries the high-frequency reads, so the L2 mostly
handles cross-instance coherence and warm restarts — a load Postgres serves comfortably. Reach for a
dedicated tier such as Redis when the cache itself must be a very high-throughput, independently scaled,
or multi-region store, where routing all cache traffic through your primary Postgres (and the
"not on replicas" limitation of UNLOGGED) would make the database the bottleneck. Any IDistributedCache
works as the L2: register Redis (AddStackExchangeRedisCache), SQL Server, or another provider before
AddElarionHandlerCaching() and HybridCache uses it just the same.
How it composes
CacheDecorator<TRequest, TResponse> and CacheInvalidationDecorator<TRequest, TResponse> are
ordinary handler decorators inserted by the [Cacheable] / [CacheInvalidate] attributes. They sit
in the generated pipeline alongside your application decorators, so caching composes deterministically
with logging, validation, and transactions. Cache operations are also
trace-visible: get/create spans report the precise outcome —
miss-factory-executed, miss-non-cacheable, or cached-or-coalesced — and invalidation emits its
own spans, all without leaking full keys or raw user ids into tags.