Skip to content

About

Source-generated zero-allocation caching proxy for .NET — annotate an interface with [Cache] and get a fully wired IMemoryCache or HybridCache proxy at compile time

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

241 Commits

Folders and files

Repository files navigation

ZeroAlloc.Cache

NuGet Build License: MIT AOT GitHub Sponsors

Source-generated zero-allocation caching proxy from an annotated interface.

Add [Cache] to an interface and a Roslyn source generator emits a proxy class that transparently intercepts every method call, returning a cached result on hit with no heap allocation on the cache-hit path for ValueTask<T> methods on the default MemoryCache on .NET 9 and later. The few cases that still allocate are listed. Backed by IMemoryCache by default, with optional HybridCache (L1 + L2) opt-in per method. AOT-safe.


Quick start

dotnet add package ZeroAlloc.Cache
[Cache(TtlMs = 60_000)]
public interface IProductRepository
{
    ValueTask<Product?> GetByIdAsync(int id, CancellationToken ct);

    [Cache(TtlMs = 300_000, MaxEntries = 1_000)]
    ValueTask<IReadOnlyList<Product>> SearchAsync(string query, CancellationToken ct);
}

// Register — one line wires everything
builder.Services.AddProductRepositoryCache<ProductRepositoryImpl>();

Inject IProductRepository anywhere — caching is transparent to the caller.

public class ProductsController(IProductRepository repo)
{
    public async Task<Product?> Get(int id, CancellationToken ct)
        => await repo.GetByIdAsync(id, ct); // the proxy's cache hit allocates nothing
}

Performance

L1 (in-process) cache-hit comparison. .NET 10.0.12, i9-12900HK, BenchmarkDotNet v0.15.8.

Library Time Allocated
Raw IMemoryCache.GetOrCreateAsync 157 ns 104 B
ZA.Cache proxy 198 ns 0 B
FusionCache 1,270 ns 88 B

ZA.Cache is about 6× faster than FusionCache and allocates nothing on the hit. The ~1.3× premium over hand-rolled IMemoryCache.GetOrCreateAsync is the cost of the typed [Cache] attribute abstraction (proxy dispatch, key formatting, telemetry) — in exchange you don't write the lookup boilerplate at every call site. FusionCache's overhead comes from carrying L2-cache and stampede-protection infrastructure even when only L1 is configured.

Full methodology + design analysis: docs/performance.md.

Features

Feature Notes
Zero allocation on cache hit On .NET 9+ with MemoryCache, a hit formats the key into a stack buffer and looks it up as a span, so a ValueTask<T> hit allocates nothing. Where a hit still allocates
IMemoryCache (default) In-process L1 cache; no extra dependencies
HybridCache (opt-in) L1 + L2 distributed cache via Microsoft.Extensions.Caching.Hybrid
Method-level override Any [Cache] on a method shadows the interface-level config for that method
MaxEntries Moves the method to an isolated MemoryCache with a SizeLimit, shared by all bounded methods of the interface for the lifetime of the container
Compile-time key The key expression is emitted by the generator. The key text is Interface.Method:arg1:arg2, the same on every path, so an entry can be read or removed by it, see Cache keys
AOT / trimmer safe Generated proxy is concrete; no reflection at runtime
DI integration Generated Add{Service}Cache<TImpl>() extension registers everything, e.g. AddProductRepositoryCache for IProductRepository

Cache behavior

Scenario Behavior
Miss Inner implementation is called; result is stored in cache with the configured TTL; result is returned
Hit Cached value is returned directly; inner implementation is never invoked; no heap allocation, with exceptions

Cache keys

Each entry is stored under the string {Interface}.{Method}:{arg1}:{arg2}, built from every parameter except the CancellationToken, each formatted with the current culture. A method without key parameters uses {Interface}.{Method}. To evict an entry from the shared IMemoryCache, remove it by that string:

cache.Remove($"IProductRepository.GetByIdAsync:{id}");

HybridCache methods use the same key text. For an interface nested in another type, {Interface} starts with the containing types, as in Catalog.IProductLookup.GetByIdAsync:42. When two interfaces in the project would use the same {Interface}, such as Shipping.IProductLookup and Billing.IProductLookup, both start with their namespace instead, as in Shipping.IProductLookup.GetByIdAsync:42, so they never read each other's entries. Every other interface keeps the shorter text.


Telemetry

Each cached method emits both metrics (via Meter("ZeroAlloc.Cache")) and a tracing span (via ActivitySource("ZeroAlloc.Cache")) — no extra package required, plain BCL System.Diagnostics.

Breaking change in 2.0: Meter name renamed from "zeroalloc.cache" to "ZeroAlloc.Cache" for ecosystem consistency with the other ZeroAlloc telemetry packages. Subscribers must update — calls to AddMeter("zeroalloc.cache") will silently stop receiving metrics:

-services.AddOpenTelemetry().WithMetrics(m => m.AddMeter("zeroalloc.cache"));
+services.AddOpenTelemetry().WithMetrics(m => m.AddMeter("ZeroAlloc.Cache"));

Metrics. Counters tagged with method (the cached method name): cache.hits, cache.misses, cache.evictions, cache.hybrid_calls (factory invocations on the HybridCache path). The cache.lookup_duration_ms histogram records per-lookup latency tagged with cache.method.

Tracing. Each cached method emits a cache.lookup span tagged with:

Tag Value Notes
cache.method "Interface.Method" Compile-time constant per emitted method
cache.tier "L1" or "L2" L1 = in-process MemoryCache; L2 = HybridCache
cache.hit true / false L1 only — HybridCache hides per-call hit/miss state, so this tag is omitted on the L2 path

Subscribe via OpenTelemetry:

services.AddOpenTelemetry()
    .WithMetrics(m => m.AddMeter("ZeroAlloc.Cache"))
    .WithTracing(t => t.AddSource("ZeroAlloc.Cache"));

Diagnostics

ID Severity Description
ZC0001 Warning Sliding = true combined with UseHybridCache = true — sliding TTL is silently ignored by the distributed (L2) tier
ZC0002 Warning A cache key parameter is a reference type (excluding string) — ToString() may not produce a stable unique key
ZC0003 Error UseHybridCache = true without a reference to Microsoft.Extensions.Caching.Hybrid — no proxy is generated for the interface
ZC0004 Warning Bounded methods on one interface set different MaxEntries values — they share one size-limited cache sized by the first bounded method
ZC0005 Warning [Cache] on a method that does not return Task<T> or ValueTask<T> — caching is not applied
ZC0006 Warning A nested interface whose containing type is not partial — no proxy is generated for it
ZC0007 Warning A generic interface, or one nested in a generic type — no proxy is generated for it
ZC0008 Warning A private or protected nested interface, which the namespace-level Add…Cache method cannot name — no proxy is generated for it
ZC0009 Error A file-local interface — no proxy is generated for it
ZC0010 Error An interface whose name differs only in case from another's — only the first is generated

Documentation

Full docs live in docs/:


License

MIT

About

Source-generated zero-allocation caching proxy for .NET — annotate an interface with [Cache] and get a fully wired IMemoryCache or HybridCache proxy at compile time

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages