Articles · Projections & Read Models ·009
Caching Queries
Runnable sample on GitHub
Sample:
samples/009-caching-queries—[Cacheable]+ a deterministic hit/miss counter.dotnet runto see it,dotnet testto 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:
- 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.Removecalls that someone always forgets, and now users see stale data with no obvious cause. - 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<T>() → 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.
DurationSecondsis 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.
AddRelayRedisCacheshares 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 callInvalidateQueryAsync. 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
IMemoryCachekeyed 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)