Elarion

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

PropertyDefaultMeaning
tags (constructor)noneLogical cache tags for grouping and invalidation.
DurationSeconds60Entry lifetime, applied to both the distributed and local cache layers.
ScopeCurrentUserWhether keys and tags are isolated per user or shared globally.
KeyPropertiesall public request propertiesWhich 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:

ScopeBehavior
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.
GlobalEntries are shared across all users. Use only for data that is not user-specific and carries no authorization context.

Both scopes sit inside the tenant when tenant scoping is registered: Global then means every user of the tenant in scope.

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 Global invalidation (the default) evicts the globally shared entries and every user's CurrentUser-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 CurrentUser invalidation 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.

Tenant isolation

A cache hit never runs the handler, so it never runs the tenant read filter either. Once tenant scoping is registered (AddElarionTenantScoping()), the default cache therefore puts the tenant in scope into every key and tag, whatever the Scope. A [Cacheable(Scope = HandlerCacheScope.Global)] query over tenant-scoped rows caches one entry per tenant, and a user who belongs to several tenants gets one CurrentUser entry per tenant. Nothing changes on the handler or its attributes.

In scopeReads[CacheInvalidate] evicts
A tenantCached for that tenant only.That tenant's entries, every user's at the Global default, plus entries cached under a system scope. Other tenants keep theirs.
SystemScope()Cached in a namespace of its own, never served to a tenant.The listed tags in every tenant.
No tenant resolvedNot cached: the handler runs on every call.The listed tags in every tenant.

A tenant's id is hashed before it enters a key or tag, like a user id. A host without tenant scoping keeps exactly the keys and tags it had before.

No tenant means no cache. This fails closed, the same way the read filter matches no rows when the tenant is unresolved. A shared namespace for every caller without a tenant would be keyed on a missing value: a handler that enters a tenant itself, with ITenantContext.Scope in its body, would store that tenant's result there and serve it to the next caller without a tenant. Bypassing gives up caching for those callers instead, typically anonymous visitors when the tenant comes from a claim. To cache their reads, resolve a tenant for them before the pipeline runs (from the host name, say).

Invalidation stays inside the tenant. A tenant's writes are guarded to its own rows, so its [CacheInvalidate] leaves other tenants' entries alone, and one busy tenant cannot keep flushing everyone else's cache. A system scope and a scope with no tenant are not confined to one tenant's rows, so their invalidations reach every tenant.

The cache cannot tell what a handler reads, so data that is not tenant-scoped is cached per tenant as well. When such data changes inside a tenant — a user's own profile, cached in each of their tenants — only that tenant's copy is evicted, and the other copies stay until they expire. Dispatch a command that changes shared data inside a system scope, which evicts every tenant, or keep DurationSeconds short for reads of such data.

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 UNLOGGED table; Redis, SQL Server, and others work too.
  • Swap the HybridCache implementation itself. HybridCache is the abstraction, and HybridHandlerCache simply resolves whatever HybridCache is in the container. Register your own HybridCache before AddElarionHandlerCaching() — its AddHybridCache() call uses TryAddSingleton, so it adds the default only when one is not already registered, and your implementation wins.
  • Replace IHandlerCache entirely when you want to bypass HybridCache and own the whole caching contract. A replacement then owns tenant isolation as well: the generated policies' keys contain only request values.

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, cached-or-coalesced, or bypass-no-tenant — and invalidation emits its own spans, all without leaking full keys, raw user ids, or tenant ids into tags.

On this page