Elarion
Core Concepts

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)]
[RpcMethod("clients.get")]
public sealed class GetClient(IAppDbContext 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. Invalid names are reported at build time.

Use KeyProperties when only some request fields affect the response:

[Cacheable("reports", DurationSeconds = 300, KeyProperties = new[] { "Year", "Month" })]

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")]
[RpcMethod("clients.update")]
public sealed class UpdateClient(IAppDbContext 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]. Tag and scope must line up: a CurrentUser-scoped invalidation clears that user's clients entries; a Global one clears the shared entries.

Registering the cache runtime

The attributes and generated policies are runtime-neutral. The host chooses the implementation. The default is backed by HybridCache:

builder.Services.AddElarionHandlerCaching();

This registers HybridCache and the default IHandlerCache (HybridHandlerCache). There are three ways to change the backing store, from least to most invasive:

  • Configure HybridCache with a distributed cache (for example Redis) as you normally would — the default two-tier behavior, just with an L2 added.
  • 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.

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