Articles · Projections & Read Models ·009

Caching Queries

Runnable sample on GitHub

Sample: samples/009-caching-queries[Cacheable] + a deterministic hit/miss counter. dotnet run to see it, dotnet test to prove it. No database.

Prerequisites: 001 — Getting Started. Pairs naturally with 007 — Projections.

Overview

A read model (article 007) already makes queries fast — a single indexed SELECT. But the hottest queries, run thousands of times a second with identical parameters, don’t need to hit the database at all. Relay’s query caching memoises a query’s result so identical queries within a window are served from memory (or Redis), with stampede protection so a cache miss under load doesn’t unleash a thundering herd, and command-driven invalidation so a write evicts the stale result. It is one attribute on the query and one line in a command.

Why this exists

Even a fast query has a cost, and the hot ones pay it constantly: the dashboard query every page load, the “categories” list on every request, the expensive aggregation behind a popular report. Serving the same answer from the database thousands of times a second is wasteful when the answer rarely changes. The naive fix — sprinkle IMemoryCache.GetOrCreate calls through handlers — works until you hit the two problems that make caching genuinely hard:

  1. Invalidation. “There are only two hard things in computer science…” — a cached result is a lie the moment the underlying data changes. Hand-rolled caching scatters cache.Remove calls that someone always forgets, and now users see stale data with no obvious cause.
  2. Stampedes. When a hot cache entry expires, every concurrent request misses at once and they all recompute simultaneously — a self-inflicted load spike exactly when the system is busiest.

Relay’s caching addresses both as framework concerns. The cache key is derived deterministically from the query and its parameters (and the tenant — see below), so identical queries share an entry automatically. Invalidation is an explicit, discoverable call on the command that changed the data. And the behavior coalesces concurrent misses (stampede protection) so only one request recomputes while the others wait for its result.

When to use this

Cache a query when it is read-heavy, repeated with identical parameters, and tolerant of brief staleness:

  • Hot lists & lookups. Categories, navigation, feature flags, “popular items” — read on nearly every request, changed rarely. The canonical win.
  • Expensive aggregations / reports. A dashboard rollup that takes 200 ms to compute and is viewed by hundreds of users — compute once, serve from cache until it changes.
  • Reference data. Currency lists, tax tables, country lists — effectively static, queried constantly.
  • Per-tenant hot data (with tenant-aware keys — article 020) — a tenant’s dashboard that’s hot for that tenant.

The signal: the same query, same parameters, same answer, over and over, where being a few seconds stale is fine. That describes a surprising share of an application’s read traffic.

When not to use this

  • Cheap, rarely-repeated queries. Caching a fast SELECT … WHERE id = ? that’s never queried twice with the same id adds a key computation and a cache round-trip for no benefit — sometimes slower than the query.
  • Data that must be strongly consistent on read. “Show the user their current balance immediately after they deposit” — caching introduces a staleness window that, here, is a correctness bug. Don’t cache reads that must reflect the latest write the instant it happens.
  • Highly-variable parameters. A query keyed by a high-cardinality parameter (a free-text search, a timestamp) rarely repeats, so the cache hit rate is near zero and you’re paying overhead for nothing.
  • Write-heavy data. If the underlying data changes constantly, you invalidate as fast as you cache — churning the cache for no hit rate.

The costs: a staleness window (the central tradeoff — you trade freshness for speed); an invalidation discipline (every command that changes cached data must invalidate it, or you serve stale results); memory/Redis cost for the cached entries; and a tenant-isolation hazard (a mis-keyed cache can serve one tenant’s data to another — see Mistakes).

Concepts

[Cacheable]. An attribute on a query (DurationSeconds, default 300). It triggers the built-in QueryCachingBehavior — which only runs for queries that carry the attribute — to cache the result.

Cache key. Derived deterministically: query type name + the query’s serialized parameters (SHA-256), plus a tenant prefix when multi-tenancy is in play. Identical queries produce identical keys and share an entry; different parameters get different entries; different tenants never collide.

ICachingStrategy. The backend: InMemoryCachingStrategy (per-instance, AddRelayInMemoryCache) or RedisCachingStrategy (shared across instances, AddRelayRedisCache). Same behavior, different reach.

Stampede protection. The behavior coalesces concurrent misses for the same key (a striped lock): the first request computes, the rest wait for its result, so a hot key’s expiry doesn’t trigger N simultaneous recomputations. Telemetry counts hits, misses, and stampede waits.

Invalidation. IQueryCacheInvalidator.InvalidateQueryAsync<TQuery>() evicts every cached result of a query type. A command that mutates the data a query reads calls this, so the next read recomputes. Graceful degradation: if the cache backend is down, the behavior falls back to executing the query (a cache failure degrades to a miss, never to a request failure).

Architecture

sequenceDiagram
    participant Q as IQueryBus
    participant CB as QueryCachingBehavior ([Cacheable] only)
    participant Cache as ICachingStrategy
    participant H as Query handler
    Q->>CB: GetCatalogStatsQuery
    CB->>Cache: get key(type + params + tenant)
    alt hit
        Cache-->>CB: cached result
        CB-->>Q: result (handler NOT run)
    else miss
        CB->>CB: acquire stripe lock (stampede protection)
        CB->>H: run handler
        H-->>CB: result
        CB->>Cache: set(key, result, TTL)
        CB-->>Q: result
    end
    Note over CB,Cache: a command calls InvalidateQueryAsync&lt;T&gt;() → evict by prefix

Building it step by step

The sample is samples/009-caching-queries.

1. Mark the query cacheable

[Cacheable(DurationSeconds = 60)]
public sealed record GetCatalogStatsQuery : IQuery<CatalogStats>;

2. The handler is unchanged — it just runs less often

public sealed class GetCatalogStatsQueryHandler(CatalogStore store, QueryExecutionCounter counter)
    : IQueryHandler<GetCatalogStatsQuery, CatalogStats>
{
    public Task<CatalogStats> Handle(GetCatalogStatsQuery query, CancellationToken ct)
    {
        counter.Increment();                       // only reached on a cache MISS
        var (sales, revenue) = store.Snapshot();
        return Task.FromResult(new CatalogStats(sales, revenue));
    }
}

3. Invalidate from the command that changes the data

public sealed class RecordSaleCommandHandler(CatalogStore store, IQueryCacheInvalidator cacheInvalidator)
    : ICommandHandler<RecordSaleCommand>
{
    public async Task Handle(RecordSaleCommand command, CancellationToken ct)
    {
        store.RecordSale(command.Amount);
        await cacheInvalidator.InvalidateQueryAsync<GetCatalogStatsQuery>(ct); // next read recomputes
    }
}

4. Register a cache backend

services.AddRelay(typeof(Program).Assembly);
services.AddRelayInMemoryCache();   // or AddRelayRedisCache(o => o.ConnectionString = "...") for multi-instance

That’s the whole feature: one attribute, one invalidation call, one registration.

Complete source code

File Contents
Catalog/Queries.cs The [Cacheable] query + handler
Catalog/Commands.cs The command that mutates + invalidates
ReportingServiceCollectionExtensions.cs AddRelay + AddRelayInMemoryCache

Running the example

dotnet run --project samples/009-caching-queries/Reporting
After two identical queries, the handler ran 1 time(s).
After a sale + invalidation, the handler ran 2 time(s); revenue = 49.99.

Testing

The execution counter makes caching observable without flaky timing assertions — a cache hit skips the handler, so the counter is the ground truth:

[Fact]
public async Task Identical_cacheable_queries_run_the_handler_only_once()
{
    var queries = provider.GetRequiredService<IQueryBus>();
    var counter = provider.GetRequiredService<QueryExecutionCounter>();
    await queries.Execute<GetCatalogStatsQuery, CatalogStats>(new GetCatalogStatsQuery(), default);
    await queries.Execute<GetCatalogStatsQuery, CatalogStats>(new GetCatalogStatsQuery(), default);
    counter.Count.Should().Be(1, "the second identical query is served from the cache");
}

[Fact]
public async Task Invalidation_forces_the_next_query_to_recompute()
{
    await queries.Execute<GetCatalogStatsQuery, CatalogStats>(new GetCatalogStatsQuery(), default); // miss → 1
    await commands.Execute<RecordSaleCommand>(new RecordSaleCommand(10m), default);                  // evicts
    await queries.Execute<GetCatalogStatsQuery, CatalogStats>(new GetCatalogStatsQuery(), default);  // miss → 2
    counter.Count.Should().Be(2);
}

Testing caching by behavior (did the handler run?) rather than by timing (was it faster?) is the difference between a deterministic test and a flaky one. These two tests pin the two things that matter: cache hits skip work, and invalidation un-skips it.

Production considerations

  • TTL is your staleness budget — set it per query. A categories list can be stale for minutes; a “current promotions” banner maybe seconds. DurationSeconds is that budget; choose it from how stale the data may safely be, not a global default.
  • Invalidate eagerly and keep a sane TTL. Command-driven invalidation gives freshness; the TTL is a backstop for the invalidations you forgot or the data that changes outside your commands (a batch job, another service). Use both: invalidate on write, and let the TTL bound worst-case staleness.
  • Use Redis for multi-instance. In-memory caching is per-process — instance A’s invalidation doesn’t evict instance B’s copy, so behind a load balancer you’d serve inconsistent staleness. AddRelayRedisCache shares one cache (and one invalidation) across all instances. The behavior and your code are identical; only the registration changes.
  • Tenant isolation is security-critical. With multi-tenancy (article 020), the cache key includes a tenant prefix so tenant A can never receive tenant B’s cached result. If you implement your own caching, this is the bug that becomes a data breach — let the framework key it.
  • Watch the hit rate. Relay emits hit/miss/stampede-wait/eviction metrics. A low hit rate means you cached the wrong query (parameters too variable); a high stampede-wait count means a very hot key expiring — both are tuning signals.
  • Cache is an optimization, never a source of truth. It can be cold, evicted, or down at any moment; the system must be fully correct with the cache empty (the graceful-degradation path ensures a cache failure becomes a miss, not an error). Never store the only copy of anything in the cache.

Common mistakes

  • Caching without invalidating. The classic stale-data bug: a [Cacheable] query whose data is changed by a command that doesn’t call InvalidateQueryAsync. Users see old data until the TTL expires, with no obvious cause. Every write to cached data must invalidate it.
  • Caching cheap or high-cardinality queries. A fast point-lookup or a query keyed by free-text search rarely repeats; caching it adds overhead for a near-zero hit rate. Cache the hot, repeated queries, not everything.
  • Rolling your own cache and forgetting the tenant key. Hand-rolled IMemoryCache keyed by query parameters but not tenant will serve one tenant’s data to another. This is a breach, not a bug. Use the framework’s tenant-aware keying.
  • TTLs that are too long. A 24-hour TTL on data that changes daily means up to a day of staleness on any invalidation you miss. Set TTLs to your actual staleness tolerance.
  • In-memory cache behind a load balancer expecting global invalidation. Invalidating on instance A doesn’t touch instance B’s in-memory copy. Use Redis when you run more than one instance and need consistent invalidation.
  • Treating the cache as durable. Putting data only in the cache (no source of truth) loses it on eviction/restart. The cache is a copy; the read model / event store is the truth.

Tradeoffs

Benefits. Hot queries skip the database entirely; stampede protection prevents self-inflicted load spikes; invalidation is explicit and discoverable; keys are deterministic and tenant-safe by construction; and a cache outage degrades to a miss, never a failure.

Costs. A staleness window (the fundamental trade); an invalidation discipline every relevant command must follow; memory/Redis cost; and a tenant-isolation responsibility that, if you bypass the framework, becomes a security hazard.

Alternatives

  • No caching (just the read model). Article 007’s projection already makes reads a single indexed query. For many systems that is fast enough, and “no cache” means no staleness and no invalidation to get wrong. Add caching only for the queries hot enough to justify it — most aren’t.
  • HTTP caching (ETags / Cache-Control). Cache at the edge / browser, upstream of your service. Excellent for cacheable GET responses and offloads your service entirely, but it caches responses, not queries, and invalidation is coarser (you don’t control the client’s cache precisely). Often used with query caching, at a different layer.
  • Database / materialised-view caching. Let the database cache query plans/results or maintain a materialised view. Useful when the source is relational and the view is SQL-expressible; less flexible than application caching and tied to the database. Complementary.
  • Read replicas. Scale read throughput of the same query. Orthogonal: replicas give you more capacity for the query; caching removes the query. Frequently combined.

Objectively: query caching wins for hot, repeated, staleness-tolerant reads. For everything else, the read model alone is simpler and fresher — don’t cache what isn’t hot.

Lessons from production systems

  • Invalidation, not caching, is where teams get burned. Adding [Cacheable] is easy and feels like free speed; the stale-data incident arrives weeks later when a write path nobody connected to the cache changes the data. Make “what invalidates this?” part of designing any cached query, and prefer eager invalidation plus a conservative TTL backstop.
  • The in-memory vs Redis decision sneaks up at scale-out. Single-instance, in-memory caching is perfect — until you add a second instance and users get inconsistent staleness depending on which instance they hit. Decide your cache backend when you decide to run more than one instance, not after the confusing bug reports.
  • Tenant cache bugs are the scariest caching bugs. A cross-tenant cache leak is a data breach with a trivial root cause (a key missing the tenant). The teams that never hit it let the framework key the cache; the ones that hand-roll caching eventually ship the leak. Don’t hand-roll tenant-scoped caching.
  • Most queries shouldn’t be cached, and that’s fine. The instinct to cache everything produces low hit rates, churn, and staleness risk for negligible benefit. The teams that profile first cache the handful of genuinely hot queries and leave the rest to the read model — simpler, fresher, and just as fast where it matters.

Should you use this?

Situation Recommendation
Hot, repeated, staleness-tolerant reads (lists, dashboards, reference data) Strong fit
Expensive aggregations viewed by many users Strong fit
Multi-instance deployment needing shared cache Strong fit — use Redis
Reads that must reflect the latest write immediately Avoid — staleness is a correctness bug here
Cheap or high-cardinality (rarely-repeated) queries Avoid — overhead, near-zero hit rate
Write-heavy data Lean against — you invalidate as fast as you cache

Next steps

You now have a complete CQRS + event-sourcing stack: a transactional write side, durable events, read-model projections with operations, and query caching. So far everything has happened inside one service. The remaining articles cross service boundaries and address time: reliably publishing events to other services (the outbox, article 011), consuming them idempotently (the inbox), coordinating long-running workflows (sagas), and scheduling work for the future. The next article opens that door with the pattern that makes cross-service messaging reliable without dual writes.

➡️ 010 — Authorization (planned) · then 011 — The Outbox Pattern (planned — see the coverage plan)