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.

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.

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.

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.

On this page