DotNetQuery.SourceGenerator 0.1.0

dotnet add package DotNetQuery.SourceGenerator --version 0.1.0
                    
NuGet\Install-Package DotNetQuery.SourceGenerator -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="DotNetQuery.SourceGenerator" Version="0.1.0">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="DotNetQuery.SourceGenerator" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="DotNetQuery.SourceGenerator">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add DotNetQuery.SourceGenerator --version 0.1.0
                    
#r "nuget: DotNetQuery.SourceGenerator, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package DotNetQuery.SourceGenerator@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=DotNetQuery.SourceGenerator&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=DotNetQuery.SourceGenerator&version=0.1.0
                    
Install as a Cake Tool

NuGet NuGet NuGet NuGet NuGet CI

DotNetQuery

Caching that behaves like React Query, for .NET.

DotNetQuery is a small, framework-agnostic query cache. You read through it, you write through it, and it keeps your reads correct and fast:

  • Reads go through a cache addressed by a hierarchical, serializable QueryKey.
  • Writes invalidate any number of related queries in O(1) — no scanning, no flushing, no key bookkeeping.
  • A single client applies four proven cache-stampede defences (single-flight, stale-while-revalidate, probabilistic early expiration, TTL jitter).
  • Storage is pluggable: process memory for one instance, Redis for a fleet, or a file for offline client apps.

New to caching? Start with What is a query cache? and the Glossary. You do not need to understand the internals to use it — the Setup and Usage sections are copy-paste friendly.


Table of contents


What is a query cache?

A query cache sits between your code and your data source (a database, an API, a service). Instead of calling the source every time, you ask the cache:

"Give me the value for this key. If you don't have a fresh copy, run this function once to get it, remember the result, and give it back."

flowchart LR
    A["Your handler"] -->|"get(key, fetch)"| B{"Fresh copy<br/>in cache?"}
    B -->|"yes"| C["Return the cached value"]
    B -->|"no"| D["Run your fetch function<br/>(once, even under load)"]
    D --> E["Store the value with a TTL"]
    E --> C

The hard parts of doing this well — and the parts DotNetQuery handles for you — are:

  1. Freshness. When is a cached value still good? (Time-to-live, plus a safety window.)
  2. Stampedes. When a popular key expires, hundreds of requests miss at once. You must not run the query hundreds of times.
  3. Invalidation. When data changes, how do you clear everything that depends on it — cheaply and correctly?

If those three words are new, read the Glossary; it decodes every term used in this README.


Why DotNetQuery

You want DotNetQuery gives you
Fewer database calls on hot reads A read-through cache with a single line of setup
No "thundering herd" when a key expires Single-flight + jitter + early expiration built in
Correct invalidation after a write Tag-based invalidation that is O(1) regardless of how many entries match
The same cache for many server instances A Redis store with atomic, distributed invalidation
To not write caching boilerplate by hand A source generator: annotate a handler, get a cache decorator
To see what the cache is doing Live statistics + OpenTelemetry metrics and traces

It is deliberately small and composable: all cache semantics live in one client, and the storage is a single interface (IQueryStore). Swapping storage never changes behaviour.


Architecture

DotNetQuery is a set of small packages around one client. Your application talks to IQueryClient; the client talks to a pluggable store; an optional event sink reports what happened.

flowchart TD
    App["Your application / handlers"]

    subgraph DX["Compile-time DX (optional)"]
        Attr["[Cache] / [InvalidateCache] attributes"]
        Gen["DotNetQuery.SourceGenerator<br/>(Roslyn generator + analyzer)"]
        Wrap["Generated decorator wrappers"]
        Attr --> Gen --> Wrap
    end

    App --> Attr
    App -->|"IQueryClient"| Client

    Wrap -->|"IQueryClient"| Client
    Client --> Store["IQueryStore"]
    Client --> Sink["IQueryEventSink"]

    Store --> Mem["InMemoryQueryStore"]
    Store --> Redis["RedisQueryStore"]
    Store --> File["FileBackedQueryStore"]
    Sink --> Stats["QueryStatistics / OpenTelemetry"]

The layers

Layer What it does Where it lives
Contracts IQueryClient, QueryKey, CachePolicy, IQueryStore, IQueryEventSink, … DotNetQuery.Abstractions
Client Freshness, single-flight, retries, SWR, invalidation — all the semantics DotNetQuery.Core (QueryClient)
Store Dumb storage: get/set/remove + numeric tag versions DotNetQuery.Redis, InMemoryQueryStore, DotNetQuery.Persistence
DX Turns annotations into decorators at compile time DotNetQuery.SourceGenerator
Observability Counters, histograms, traces DotNetQuery.DevTools

Key idea: the store knows nothing about freshness or stampedes. It just stores bytes and bumps counters. That is why the same behaviour holds whether you run on memory or Redis.

The read path (what happens on every FetchAsync)

sequenceDiagram
    autonumber
    participant C as Caller
    participant Q as QueryClient
    participant S as IQueryStore
    participant F as Your fetch function

    C->>Q: FetchAsync(options)
    Q->>S: Get(physicalKey)
    alt fresh entry
        S-->>Q: entry
        Q-->>C: QueryResult(FromCache = true, IsStale = false)
    else stale but servable (SWR window)
        S-->>Q: entry (stale)
        Q-->>C: QueryResult(FromCache = true, IsStale = true)
        Q--)F: revalidate in the background
    else miss
        Q->>Q: acquire single-flight gate (per key)
        Q->>S: re-check (another caller may have filled it)
        Q->>F: run fetch (once)
        F-->>Q: value
        Q->>S: Set(entry, ttl)
        Q-->>C: QueryResult(FromCache = false)
    end

The write/invalidation path

flowchart LR
    W["A write succeeds"] --> I["MutateAsync(..., Invalidate = [tags])"]
    I --> V["Bump each tag's version<br/>O(1) — one counter"]
    V --> K["Every later read builds a<br/>NEW physical key"]
    K --> H["Next read misses → recompute once"]

A tag version is a single number. Rebuilding a key from a new number points every future read at a fresh key — that is why invalidation never scans the cache.


Setup

1. Install

dotnet add package DotNetQuery                 # client + in-memory store
dotnet add package DotNetQuery.Redis           # shared Redis store (multi-instance)
dotnet add package DotNetQuery.DevTools        # stats + OpenTelemetry (optional)
dotnet add package DotNetQuery.Persistence     # offline file cache (client apps only)

DotNetQuery.Abstractions comes in transitively; add it explicitly only when you build a custom store, clock, or event sink.

2. Register the client + a store

using DotNetQuery;
using StackExchange.Redis;

var redis = builder.Configuration.GetConnectionString("Redis")!;

builder.Services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(redis));
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = redis);

builder.Services.AddDotNetQuery();      // IQueryClient + QueryClientOptions
builder.Services.AddRedisQueryStore();  // IQueryStore → RedisQueryStore

For a single instance (or tests), skip Redis:

builder.Services.AddDotNetQueryInMemory();   // in-memory store + client, one call

Order does not matter — the client resolves IQueryStore lazily.

Registering both an IDistributedCache and an IConnectionMultiplexer unlocks the distributed single-flight lock. Without the multiplexer the Redis store still works, but coalescing falls back to in-process only.

If you want annotate-a-handler-and-forget caching (see Style A), also do two things:

// a) Reference the generator. In-repo:
<ProjectReference Include="…/DotNetQuery.SourceGenerator.csproj"
                  OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
// From NuGet, the analyzer ships inside the package — nothing extra to wire.

// b) Declare the ambient identity names your keys may bind to, and supply their values.
[assembly: CacheContext(typeof(IAppCacheContext))]

public interface IAppCacheContext   // property names = bindable cache-key names
{
    Guid UserId { get; }
    Guid? BusinessId { get; }
    Guid? StoreId { get; }
    Guid? DriverId { get; }
}

// Host implementation — MUST read only server-side request state (never client input),
// and MUST return null (not Guid.Empty) for an absent identity.
internal sealed class AppCacheContext(ICurrentUserService user) : ICacheContext
{
    public Guid? GetGuid(string name) => name switch
    {
        nameof(IAppCacheContext.UserId)     => NonEmpty(user.UserId),
        nameof(IAppCacheContext.BusinessId) => NonEmpty(user.BusinessId),
        nameof(IAppCacheContext.StoreId)    => NonEmpty(user.StoreId),
        nameof(IAppCacheContext.DriverId)   => NonEmpty(user.DriverId),
        _ => null
    };

    private static Guid? NonEmpty(Guid? v) => v is null || v.Value == Guid.Empty ? null : v;
}

builder.Services.AddScoped<ICacheContext, AppCacheContext>();

Done. Now every [Cache]/[InvalidateCache] handler gets a cache decorator generated at compile time — no hand-written caching code.


Usage

There are two styles, and you can mix them freely.

Style A — Attributes Style B — Manual
You write A key record + an attribute The cache call inline
Best for Most handlers (best DX, least boilerplate) Custom/conditional caching, non-handler services, escape hatches
Correctness Enforced by the generator + analyzer Your responsibility

Start with Style A. Reach for Style B only when A does not fit.

Style A — the DX path: attributes + source generator

The mental model: a key describes the cache identity, an attribute attaches it to a handler, and the generator writes the boring code.

flowchart LR
    K["1. Define a key<br/>(ICacheKey record)"] --> C["2. Annotate the handler<br/>[Cache(Key = typeof(K))]"]
    C --> G["3. Generator emits<br/>a decorator wrapper"]
    G --> R["4. Register it<br/>RegisterYourHandler()"]
    R --> Z["5. Resolve IHandler<br/>= cached, automatically"]
Step 1 — Define a key

A key implements ICacheKey. It maps to a QueryKey (the cache identity) and declares its policy (freshness + family tag).

using DotNetQuery;

public sealed record GetCartKey(Guid UserId) : ICacheKey
{
    public QueryKey ToQueryKey() => QueryKey.Of("cart", UserId);

    public CachePolicy Policy => new()
    {
        Ttl = TimeSpan.FromSeconds(30),                 // fresh for 30s
        StaleWhileRevalidate = TimeSpan.FromMinutes(1), // servable while refreshing
        Tags = [QueryKey.Of("cart", UserId)]            // family tag to invalidate
    };
}
Step 2 — Annotate the handler
public interface IGetCartHandler
{
    Task<CartResponse> Handle(GetCartQuery query, CancellationToken ct);
}

[Cache(Key = typeof(GetCartKey))]
public sealed class GetCartHandler : IGetCartHandler
{
    // ...your normal business logic, no caching code at all...
    public async Task<CartResponse> Handle(GetCartQuery query, CancellationToken ct) { /* … */ }
}

You have now said: "cache this read under GetCartKey." The UserId the key needs comes from ambient context — you did not pass it in the query.

Step 3 — Let the generator write the decorator

At compile time the generator produces a decorator that:

  1. resolves the key's ambient values from ICacheContext,
  2. bypasses the cache if an ambient value is missing (never shares one entry across identities),
  3. calls IQueryClient.FetchAsync with the key and policy.

Here is the shape of what it generates (you never write this):

public sealed class GetCartHandler__CacheWrapper : IGetCartHandler
{
    public async Task<CartResponse> Handle(GetCartQuery query, CancellationToken ct)
    {
        var userId = _context.GetGuid("UserId");
        if (userId is null || userId.Value == Guid.Empty)      // fail-safe: bypass
            return await _inner.Handle(query, ct);

        var cacheKey = new GetCartKey(userId.Value);
        var key = cacheKey.ToQueryKey();
        var result = await _queryClient.FetchAsync(
            new QueryOptions<CartResponse>(key, token => _inner.Handle(query, token),
                                           cacheKey.Policy.WithTags(key)),
            ct);
        return result.Value;
    }
}
Step 4 — Register it

The generator also emits Register{Handler}():

services.AddScoped<GetCartHandler>();   // or via your assembly scan
services.RegisterGetCartHandler();      // ← generated; swaps the handler for the decorator

Two rules for scans: call Register{Handler}() after any auto-registration, and exclude *__CacheWrapper types from the scan (!t.Name.Contains("__CacheWrapper")). The decorator is registered last so it wins.

Writes: invalidate on the way out
[InvalidateCache(typeof(GetCartKey))]
public sealed class AddToCartHandler : IAddToCartHandler { /* … */ }

After the handler succeeds, every query tagged with the key's family tag is invalidated in O(1). Pass several keys when a write touches multiple families:

[InvalidateCache(typeof(GetCartKey), typeof(GetWishlistKey), typeof(ProductDetailsKey))]
How the generator binds key parameters

If your key needs a value, the generator finds it in a fixed order:

flowchart TD
    P["A key constructor parameter"] --> A{"Is its<br/>NAME declared<br/>as ambient?"}
    A -->|"yes"| AMB["Bind from ICacheContext.GetGuid(name)<br/>+ guard (bypass/skip if missing)"]
    A -->|"no"| B{"Matches a Handle<br/>parameter by<br/>name AND type?"}
    B -->|"yes"| HND["Bind from that Handle argument"]
    B -->|"no"| C{"Exactly one Handle<br/>parameter of the<br/>same type?"}
    C -->|"yes"| TYP["Bind by type"]
    C -->|"no"| ERR["Compile-time error<br/>(DNQ003 / DNQ004)"]
    AMB -.->|"Handle also has that name"| CONFLICT["Error DNQ005 — rename one"]

Ambient names win over everything else, so a client-supplied argument can never shadow an identity. If you write Handle(Guid UserId, …) while UserId is ambient, you get an error telling you to rename it — that is a security guard, not a nuisance.

Style B — the manual path: IQueryClient

When attributes do not fit (conditional caching, loops, services that are not handlers), inject IQueryClient and call it directly.

public sealed class ProductQueries(IQueryClient client, IProductRepository repo)
{
    public Task<QueryResult<IReadOnlyList<Product>>> GetProductsAsync(
        Guid storeId, int page, CancellationToken ct = default)
    {
        var tag = QueryKey.Of("catalog", "products", storeId);
        var key = tag.With("page", page);

        return client.FetchAsync(
            new QueryOptions<IReadOnlyList<Product>>(
                key,
                token => repo.QueryAsync(storeId, page, token),
                new CachePolicy
                {
                    Ttl = TimeSpan.FromMinutes(5),
                    StaleWhileRevalidate = TimeSpan.FromMinutes(10),
                    Tags = [tag]
                }),
            ct);
    }
}

FetchAsync returns QueryResult<T> whose FromCache and IsStale flags tell you whether the value was fresh, stale, or freshly fetched.

A mutation that invalidates:

Guid orderId = await client.MutateAsync(
    new MutationOptions<Guid>(ct => handler.ExecuteAsync(command, ct))
    {
        Invalidate = [QueryKey.Of("catalog", "products", storeId)]
    });

OnOptimistic / OnRollback let you write an optimistic value before the mutation runs and undo it if the mutation throws.

Prefer MutateAsync over manual "write then InvalidateAsync". MutateAsync invalidates with built-in fault tolerance and logging, so a cache hiccup after your write commits cannot turn a successful write into an error.


How each stampede defence works

A cache stampede is what happens when many concurrent callers miss the same key at the same instant and all recompute it. DotNetQuery layers four defences, all driven by CachePolicy (single-flight is always on).

1. Single-flight (in-process + distributed)

Concurrent misses for the same key are coalesced. Each fetch takes a per-key gate, then re-checks the store so the winner's value is reused instead of recomputed. When the store supports distributed locking (Redis + an IConnectionMultiplexer), the winner also takes a Redis lock (SET … NX) released by compare-and-delete, so a slow holder can never delete another instance's lock. Tune with QueryClientOptions.LockTtl.

2. Stale-while-revalidate (SWR)

Each entry has a soft and a hard expiry:

  • SoftExpiresAt = StoredAt + jitter(Ttl) — freshness boundary.
  • HardExpiresAt = SoftExpiresAt + StaleWhileRevalidate — last moment it may be served.

Fresh → a hit. Between soft and hard → the stale value is returned immediately (FromCache = true, IsStale = true) while a background task refreshes it. Past hard → a miss and a synchronous fetch. StaleWhileRevalidate = TimeSpan.Zero (default) disables SWR. Background revalidation requires QueryClientOptions.EnableBackgroundRevalidation = true, and the fetch delegate must be safe to run outside the request scope.

3. Probabilistic early expiration (X-Fetch)

Instead of every instance recomputing at the same boundary, a still-fresh entry whose time-to-expiry is t seconds is recomputed with probability exp(-t / EarlyExpirationBeta): the closer to expiry, the more likely. The served value stays fresh (the recompute runs in the background). Lower EarlyExpirationBeta concentrates recomputes near expiry; higher values spread them earlier. Disable per query with EnableProbabilisticEarlyExpiration = false.

4. TTL jitter

Identical entries written together would otherwise expire in lockstep. JitterRatio (default 0.1, ±10%) multiplies each TTL by a random factor before the soft expiry is computed, so a batch of keys warms and expires at staggered times. Set JitterRatio = 0 to disable.

A failing fetch is also retried RetryCount times with exponential backoff (and only while the caller's CancellationToken is not cancelled).


Tag and version invalidation semantics

Cache identity is versioned so invalidation is cheap and never scans the store. A query's physical key is built from its tags' versions plus the query key itself:

{KeyPrefix}{tagVersion1}.{tagVersion2}…:{queryKey}
  • A query's tags are CachePolicy.Tags. When empty, the query key is its own tag.
  • InvalidateAsync(tag) increments that tag's version (a single atomic INCR on Redis; an AddOrUpdate in memory) and memoizes it.
  • Because the version is in the physical key, bumping a tag points every later read at a brand-new key. Cost is O(1) regardless of how many entries the tag covers; orphaned entries are left to hard-expire.
  • Families invalidate by shared tag. Every query in a family must declare the same tag for one InvalidateAsync to clear them all. Apply tags at the granularity you want to invalidate — a broad tag clears a family, a narrow one clears a slice.
  • RemoveAsync deletes a single physical entry without bumping any version — a surgical eviction, not a logical invalidation.

Invariant: TagVersionTtl must exceed every entry TTL

QueryClientOptions.TagVersionTtl must be strictly greater than the hard TTL (Ttl + StaleWhileRevalidate) of any entry carrying that tag — with margin. If a version counter expires while one of its entries is still within its hard TTL, the counter resets to zero, the physical key returns to its pre-invalidation value, and the invalidated entry is resurrected. The default of 365 days is safe for minute-scale TTLs; raise it if you cache with very long TTLs, never lower it below your longest entry.

QueryClientOptions.VersionCacheTtl (default 1s) memoizes tag versions in-process to avoid a store round-trip per fetch, trading a bounded cross-instance invalidation lag for throughput.


Performance

Invalidation is O(1). It bumps one number per tag; it never enumerates entries. This is the single biggest win over "scan and delete" caches.

Reads are one store hit when warm. A fresh entry is a single Get; tag versions are memoized for VersionCacheTtl. A miss is one Get + one Set (plus the fetch), with concurrent misses coalesced to one fetch.

Stampedes are absorbed, not amplified. Single-flight collapses a burst to one recompute; jitter and X-Fetch spread expiry so bursts do not line up.

What it costs per read (roughly): build the key, resolve tag versions (usually memoized), one store round-trip, done. In the attribute path the key is constructed once per call and tags are merged without extra allocation in the common single-tag case.

Tuning quick table

Symptom Knob Effect
Stale data lingers too long across instances VersionCacheTtl ↓ Faster invalidation propagation, more store reads
Too many store round-trips VersionCacheTtl ↑ Fewer reads, slightly slower propagation
Recompute storms at expiry JitterRatio ↑, keep X-Fetch on Spread expiry, recompute early
A slow source blocks the herd LockTtl ↑ (upper bound on your slowest fetch) Longer distributed single-flight window
Freshness too short/long per feature CachePolicy.Ttl Per-query control
Serve stale during a refresh CachePolicy.StaleWhileRevalidate > 0 SWR window

Measure it, do not guess

Enable DotNetQuery.DevTools and watch hit_ratio, stampede_waits, and operation durations (see DevTools). Metric dimensions are bounded — cache keys appear only on sampled spans, never as metric labels.


Developer experience (DX)

The generator does the boring parts

You annotate; it generates the decorator, the DI registration, the ambient resolution, and the guards. There is exactly one place to look for "why is this cached the way it is": the key record.

The analyzer fails the build on unsafe keys

Code Meaning Fix
DNQ001 Cache key type could not be resolved Use Key = typeof(YourKey) with a real type
DNQ002 Key does not implement ICacheKey Add : ICacheKey and ToQueryKey()
DNQ003 Key parameter cannot be bound Match a Handle parameter, or declare it ambient
DNQ004 Key looks ambient but no [CacheContext] Add [assembly: CacheContext(typeof(T))] or bind it
DNQ005 Ambient name clashes with a Handle parameter Rename one — a client value must never shadow identity
DNQ006 Handler has both [Cache] and [InvalidateCache] Split the read and write into two handlers
DNQ100 Wrapper generation failed Read the diagnostic; it carries the exception

What the generator will and will not do

Will: cache a read handler; invalidate on a write handler; resolve key parameters from Handle arguments and ambient context; guard every identity binding; multi-key invalidation; skip a handler entirely if it cannot be bound safely (with an error).

Will not: cache a handler that also writes (DNQ006 — that is a design smell); cache non-handler services; guess an ambient value (missing ⇒ bypass, never a sentinel); touch keys used only inside RemoveAsync/PrefetchAsync.

Escape hatches

When attributes do not fit, the manual IQueryClient path is always available and shares the same client, store, and semantics. Nothing about the DX layer is mandatory.


When to use it / when not

flowchart TD
    Q1{"Is the read<br/>hot and the result<br/>expensive?"} -->|yes| Q2{"Can you invalidate it<br/>when the data changes?"}
    Q1 -->|no| NO1["Probably not worth caching"]
    Q2 -->|yes| Q3{"Is serving a few<br/>seconds of stale data<br/>acceptable?"}
    Q2 -->|no| NO2["Use a write-through / exact<br/>source of truth instead"]
    Q3 -->|yes| YES["Great fit — cache it"]
    Q3 -->|no| NO3["Strong consistency —<br/>cache only reference data"]

Great fits

  • Reference data — countries, currencies, categories, plans (changes rarely).
  • Read-heavy catalogs — product listings, published store pages.
  • User-scoped reads — profile, cart, wishlist, addresses (short TTL + invalidation).
  • Expensive aggregations — dashboards, reports, analytics rollups.
  • Any read with a clear "this write changes that read" story.

Poor fits

  • Writes themselves. Never cache a write; invalidate instead.
  • Per-request unique data (a nonce, a live balance you must show exactly). Caching it is pointless or dangerous.
  • Anything where stale is unacceptable unless it is genuinely read-mostly reference data.
  • Authorization/permission decisions. Do not cache an access decision; cache the data behind a checked boundary, keyed by identity.
  • Trivially cheap queries. If the source is a dictionary lookup, a cache adds overhead.

Where to use it / where not

Where it belongs

Layer Use it for
Application read handlers / query services The main use case — cache the read, invalidate on writes
Server APIs (single or multi-instance) In-memory store (one instance) or Redis store (many)
Client apps (Blazor/MAUI/desktop) File-backed store for an offline cache across restarts

Where it does not belong

  • Inside a transaction that must be exact (e.g. a money movement). Cache around the read model, never the ledger truth.
  • For security decisions. Tenant identity is part of the key and guarded — but do not cache "is this user allowed", only "this user's data".
  • As a substitute for a database index. If a query is slow because it is unindexed, fix the query.
  • With cross-tenant keys that do not include identity. Always scope keys by every varying input (user/store/tenant), which the DX path does for ambient names automatically.

Golden rule (both styles): key = family identity + every parameter that changes the result. Miss one varying input and two different results collide in one cache entry.


DevTools and telemetry

builder.Services.AddDotNetQueryTelemetry();
builder.Services.AddDotNetQuery();

builder.Services.AddOpenTelemetry()
    .WithMetrics(m => m.AddMeter("DotNetQuery"))
    .WithTracing(t => t.AddSource("DotNetQuery"));

Counters: dotnetquery.hits, misses, stales, early_recomputes, stampede_waits, invalidations, errors, sets, removes.

Histogram (dotnetquery.operation.duration, seconds), tagged only by operation (read/write).

Observable gauges: dotnetquery.cache.hit_ratio, dotnetquery.cache.read_write_ratio.

Activities are named dotnetquery.{kind} with key/tag/operation tags when present. For an in-process snapshot (health endpoint, admin screen):

var stats = provider.GetRequiredService<QueryStatistics>();
var snapshot = stats.Snapshot();   // Hits, Misses, HitRatio, ReadWriteRatio, …
stats.Reset();

Implement IQueryEventSink yourself to feed any other sink; CompositeQueryEventSink lets you keep the built-in one alongside it.


Persistence (client hosts only)

DotNetQuery.Persistence writes the whole store to a single JSON file. Use it only in single-process client apps (Blazor, MAUI, desktop). Do not use it in a server API: it assumes a single writer, rewrites the whole file on each mutation, and reports SupportsDistributedLock = false.

builder.Services.AddFileBackedQueryStore(
    Path.Combine(FileSystem.AppDataDirectory, "dotnetquery-cache.json"));

Writes go to a temp file and are moved into place atomically, so a crash mid-write does not corrupt the cache. Expired entries are pruned on persist and on load.


Multi-targeting

Every package ships net8.0, net9.0 and net10.0 assets from one version (0.1.0). There is no dependency on the ASP.NET Core shared framework, so it works in web apps, console apps, worker services, and client hosts alike.


Appendix

A. CachePolicy reference

Member Default Meaning
Ttl 5 min How long an entry is fresh
StaleWhileRevalidate Zero Extra window to serve stale while refreshing (0 disables SWR)
JitterRatio 0.1 Random TTL variance (±) so keys do not expire in lockstep
EnableProbabilisticEarlyExpiration true May recompute slightly early (X-Fetch)
EarlyExpirationBeta 1.0 Shape of the early-recompute curve (higher ⇒ earlier, more often)
RetryCount 1 Retries after a failed fetch
RetryBaseDelay 100 ms Base delay for exponential backoff
Tags [] Family tags to invalidate; empty ⇒ the query key is its own tag
WithTags(tag) — Returns a copy with the tag merged in

B. QueryClientOptions reference

Member Default Meaning
KeyPrefix "dnq:v1:" Namespaces all keys (multiple apps on one store)
TagVersionTtl 365 days Lifetime of a tag version; must exceed every entry's hard TTL
VersionCacheTtl 1 s In-process memoization of tag versions
EnableBackgroundRevalidation true Run SWR rebuilds off the request thread
LockTtl 30 s Distributed single-flight lock lifetime
DefaultRetryCount 1 Retry default when a policy does not override it
EventSink null Where query events are reported
Clock SystemClock Injectable time source (for tests)

C. Diagnostic codes

See the DX diagnostics table.

D. Recipes

Reference data (long TTL, rarely invalidated):

public sealed record CountriesKey : ICacheKey
{
    public QueryKey ToQueryKey() => QueryKey.Of("ref", "countries");
    public CachePolicy Policy => new() { Ttl = TimeSpan.FromHours(1), StaleWhileRevalidate = TimeSpan.FromHours(6) };
}
// [Cache(Key = typeof(CountriesKey))] on ListCountriesHandler

User-scoped, short TTL + invalidation:

public sealed record GetWishlistKey(Guid UserId, int Page) : ICacheKey
{
    public QueryKey ToQueryKey() => QueryKey.Of("wishlist", UserId).With("page", Page);
    public CachePolicy Policy => new()
    {
        Ttl = TimeSpan.FromMinutes(2),
        StaleWhileRevalidate = TimeSpan.FromMinutes(5),
        Tags = [QueryKey.Of("wishlist", UserId)]   // family: page-agnostic
    };
}

High-churn live data (very short TTL):

public sealed record GetShipmentTrackingKey(Guid UserId, Guid ShipmentId) : ICacheKey
{
    public QueryKey ToQueryKey() => QueryKey.Of("tracking", UserId, ShipmentId);
    public CachePolicy Policy => new() { Ttl = TimeSpan.FromSeconds(15), StaleWhileRevalidate = TimeSpan.FromSeconds(45) };
}

Write that invalidates a family:

[InvalidateCache(typeof(GetWishlistKey))]
public sealed class RemoveFromWishlistHandler : IRemoveFromWishlistHandler { /* … */ }

E. Store selection

Store Scope Distributed lock Use when
InMemoryQueryStore One process No Single instance, tests, dev
RedisQueryStore A fleet Yes* Multiple instances / shared cache
FileBackedQueryStore One client machine No Offline client apps only

* with an IConnectionMultiplexer registered; otherwise coalescing is in-process only.

F. Package map

Package Contains
DotNetQuery.Abstractions Contracts (IQueryClient, QueryKey, CachePolicy, ICacheKey, attributes, IQueryStore, ICacheContext, …)
DotNetQuery (Core) QueryClient, InMemoryQueryStore, AddDotNetQuery, AddDotNetQueryInMemory
DotNetQuery.Redis RedisQueryStore, AddRedisQueryStore
DotNetQuery.DevTools QueryStatistics, OpenTelemetryQueryEventSink, AddDotNetQueryTelemetry
DotNetQuery.Persistence FileBackedQueryStore, AddFileBackedQueryStore
DotNetQuery.SourceGenerator Roslyn generator + analyzer for [Cache]/[InvalidateCache] (analyzer package)

Glossary

Term Plain-English meaning
Query cache A store of recent read results so repeated reads are fast.
QueryKey The ordered string identity of a cached value, e.g. catalog:products:{storeId}:page:2.
Entry One cached value with its freshness window (QueryEntry<T>).
TTL (time-to-live) How long an entry stays fresh.
Soft expiry The moment an entry stops being fresh (jittered TTL).
Hard expiry The moment an entry stops being servable at all (soft + SWR).
SWR (stale-while-revalidate) Serve the stale value now, refresh it in the background.
Stampede Many callers miss the same key at once and all recompute it.
Single-flight Coalescing concurrent misses so the fetch runs once.
X-Fetch (early expiration) Recompute a still-fresh entry probabilistically before it expires.
Jitter Randomizing TTLs so entries do not expire in lockstep.
Tag A family label attached to a query's policy, used to invalidate it.
Family A set of queries that share a tag and are invalidated together.
Physical key The real storage key: tag versions + the query key.
Invalidation Making cached values logically stale by bumping a tag version (O(1)).
Mutation A state-changing operation; it invalidates tags when it succeeds.
Ambient value A server-side identity value (user/business/store) a key binds to by name.
ICacheKey The interface a key record implements (ToQueryKey() + Policy).
Policy The CachePolicy for a key: TTL, SWR, jitter, tags.
Decorator A generated wrapper that adds caching around your handler.
Source generator A Roslyn tool that writes the decorator at compile time.
Store The dumb storage backend (IQueryStore): memory, Redis, or file.
Sink A destination for cache events (IQueryEventSink): stats, telemetry.

<p align="center"><sub>DotNetQuery — read through a cache, write through invalidation.</sub></p>

There are no supported framework assets in this package.

Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0 55 11/9/2026