Repository navigation
How Results Are Cached
What happens on a call to a [Cache] method through the proxy, and what decides whether the next call is answered from the cache. Every example ran on .NET 10, .NET 8 and .NET Framework 4.8 (Windows) with the lowest and the newest dependency sets from Getting started, and printed the same on each unless the page says otherwise.
- The proxy passes the call to
AOPCachingInterceptor. A method without[Cache](on the class's method, or a method it overrides) runs as usual. - A method returning
void,ValueTaskorValueTask<T>runs and nothing is stored. - The key is built from the call. When an argument cannot be written into it, the method runs and nothing is stored.
- An entry under that key answers the call, whatever its type.
- Otherwise the method runs under a lock for that key, and its result is stored with the attribute's lifetime, or the service's when the attribute has none.
A key is the target object's ToString(), the method name, the generic arguments, the parameter types and every argument written as JSON. This program gives MemoryCacheService a MemoryCache of its own, so the keys can be listed afterwards:
using System;
using System.Linq;
using System.Runtime.Caching;
using Autofac;
using CachingServiceWithAOP;
using CachingServiceWithAOP.CachingServices;
using CachingServiceWithAOP.Extensions;
var store = new MemoryCache("catalog");
var builder = new ContainerBuilder();
builder.RegisterCachingModule();
builder.RegisterInstance(new MemoryCacheService(store)).As<ICacheService>();
builder.RegisterType<Catalog>().As<ICatalog>().EnableCacheInterception();
using var container = builder.Build();
var catalog = container.Resolve<ICatalog>();
Console.WriteLine(catalog.Find(new Query { Sku = "A-1", Page = 2 }));
Console.WriteLine(catalog.Find(new Query { Sku = "A-1", Page = 2 }));
Console.WriteLine(catalog.Find(new Query { Sku = "A-1", Page = 3 }));
foreach (var key in store.Select(entry => entry.Key).OrderBy(key => key, StringComparer.Ordinal))
{
Console.WriteLine(key);
}
public class Query
{
public string? Sku;
public int Page { get; set; }
}
public interface ICatalog
{
string Find(Query query);
}
public class Catalog : ICatalog
{
[Cache]
public string Find(Query query)
{
Console.WriteLine("searching page " + query.Page);
return query.Sku + " page " + query.Page;
}
}It prints:
searching page 2
A-1 page 2
A-1 page 2
searching page 3
A-1 page 3
Catalog;Find;Argument Types:Query,;values:{"Sku":"A-1","Page":2}|;
Catalog;Find;Argument Types:Query,;values:{"Sku":"A-1","Page":3}|;
What follows from that key:
- Two different
Queryobjects with the same values share an entry. Arguments are compared by what they serialize to, public fields first and then public properties, never by reference. -
Cataloghere is the class'sToString(), which by default is its full type name. Every instance of a class shares entries unless the class overridesToString()(see below). - The key holds the method's name and parameter types but not the interface it came from, so two interfaces with a method of the same name and parameters share one entry (Errors and edge cases).
- Writing the key reads every public getter of every argument. An argument whose getter blocks (a pending
Task'sResult) blocks the call; the repository's notes record a test that hung that way. Not shown here. - The JSON is the format of .NET Framework's JavaScriptSerializer, as 1.0.1 wrote it. The repository's tests compare it with the real JavaScriptSerializer on .NET Framework; on .NET 8 and 10 the last digit of some doubles and floats differs. Keys only need to agree within one process, so this changes nothing for a running application.
- One instant at two UTC offsets is one key:
using System;
using Autofac;
using CachingServiceWithAOP;
using CachingServiceWithAOP.Extensions;
var builder = new ContainerBuilder();
builder.RegisterCachingModule();
builder.RegisterType<Agenda>().As<IAgenda>().SingleInstance().EnableCacheInterception();
using var container = builder.Build();
var agenda = container.Resolve<IAgenda>();
var noonUtc = new DateTimeOffset(2026, 9, 30, 12, 0, 0, TimeSpan.Zero);
Console.WriteLine(agenda.At(noonUtc));
Console.WriteLine(agenda.At(noonUtc.ToOffset(TimeSpan.FromHours(2))));
public interface IAgenda
{
string At(DateTimeOffset when);
}
public class Agenda : IAgenda
{
[Cache]
public string At(DateTimeOffset when) => "asked at offset " + when.Offset.TotalHours;
}asked at offset 0
asked at offset 0
Every MemoryCacheService made without an ObjectCache uses MemoryCache.Default, and that is what RegisterCachingModule() registers. Entries therefore outlive the instance that made them and the container that resolved it:
using System;
using Autofac;
using CachingServiceWithAOP;
using CachingServiceWithAOP.Extensions;
for (var round = 1; round <= 2; round++)
{
var builder = new ContainerBuilder();
builder.RegisterCachingModule();
builder.RegisterType<Weather>().As<IWeather>().EnableCacheInterception();
using var container = builder.Build();
var first = container.Resolve<IWeather>();
var second = container.Resolve<IWeather>();
Console.WriteLine($"container {round}: {first.Today("Oslo")}; {second.Today("Oslo")}");
}
public interface IWeather
{
string Today(string city);
}
public class Weather : IWeather
{
private static int instances;
private readonly int number = ++instances;
[Cache]
public string Today(string city) => city + " from instance " + number;
}container 1: Oslo from instance 1; Oslo from instance 1
container 2: Oslo from instance 1; Oslo from instance 1
Four instances were made, one per Resolve, and only the first one ran. A container that should not see another's entries needs a store of its own (Recipes).
An instance gets entries of its own when its ToString() tells it apart:
using System;
using Autofac;
using CachingServiceWithAOP;
using CachingServiceWithAOP.Extensions;
var builder = new ContainerBuilder();
builder.RegisterCachingModule();
builder.Register(_ => new RegionPrices("eu")).Named<IPrices>("eu").EnableCacheInterception();
builder.Register(_ => new RegionPrices("us")).Named<IPrices>("us").EnableCacheInterception();
builder.Register(_ => new SharedPrices("eu")).Named<IPrices>("shared eu").EnableCacheInterception();
builder.Register(_ => new SharedPrices("us")).Named<IPrices>("shared us").EnableCacheInterception();
using var container = builder.Build();
foreach (var name in new[] { "eu", "us", "shared eu", "shared us" })
{
Console.WriteLine(name + ": " + container.ResolveNamed<IPrices>(name).Price("A-1"));
}
public interface IPrices
{
string Price(string sku);
}
public class RegionPrices : IPrices
{
private readonly string region;
public RegionPrices(string region) => this.region = region;
public override string ToString() => "RegionPrices:" + region;
[Cache]
public string Price(string sku) => sku + " in " + region;
}
public class SharedPrices : IPrices
{
private readonly string region;
public SharedPrices(string region) => this.region = region;
[Cache]
public string Price(string sku) => sku + " in " + region;
}eu: A-1 in eu
us: A-1 in us
shared eu: A-1 in eu
shared us: A-1 in eu
SharedPrices for us answered with the eu instance's result, because both instances have the same ToString().
An entry expires at an absolute time: when it is stored, plus the lifetime. The lifetime comes from the attribute ([Cache(0, 0, 30)]), or, for a bare [Cache], from the service. RegisterCachingModule() gives five minutes; a MemoryCacheService you register yourself gives its own.
The two programs below sleep, so their output depends on time. Their margins are wide (a one-second lifetime checked after 2.5 seconds, a three-second lifetime checked after 2 and after 3.6 seconds), and two complete runs printed the same on every runtime and dependency set.
using System;
using System.Threading;
using Autofac;
using CachingServiceWithAOP;
using CachingServiceWithAOP.CachingServices;
using CachingServiceWithAOP.Extensions;
var builder = new ContainerBuilder();
builder.RegisterCachingModule();
builder.RegisterInstance(new MemoryCacheService(TimeSpan.FromSeconds(1))).As<ICacheService>();
builder.RegisterType<Rates>().As<IRates>().SingleInstance().EnableCacheInterception();
using var container = builder.Build();
var rates = container.Resolve<IRates>();
void Ask(string when)
{
Console.WriteLine($"{when}: default {rates.ServiceDefault()}, 30 s {rates.ThirtySeconds()}, zero {rates.Zero()}");
}
Ask("first");
Ask("at once");
Thread.Sleep(2500);
Ask("after 2.5 s");
public interface IRates
{
int ServiceDefault();
int ThirtySeconds();
int Zero();
}
public class Rates : IRates
{
private int runs;
[Cache]
public int ServiceDefault() => ++runs;
[Cache(0, 0, 30)]
public int ThirtySeconds() => ++runs;
[Cache(0)]
public int Zero() => ++runs;
}first: default 1, 30 s 2, zero 3
at once: default 1, 30 s 2, zero 4
after 2.5 s: default 5, 30 s 2, zero 6
The bare [Cache] took the service's one second and ran again after 2.5 seconds; [Cache(0, 0, 30)] kept its result; [Cache(0)] kept nothing and ran on every call.
Reading an entry does not extend it:
using System;
using System.Threading;
using Autofac;
using CachingServiceWithAOP;
using CachingServiceWithAOP.Extensions;
var builder = new ContainerBuilder();
builder.RegisterCachingModule();
builder.RegisterType<Clock>().As<IClock>().SingleInstance().EnableCacheInterception();
using var container = builder.Build();
var clock = container.Resolve<IClock>();
Console.WriteLine("0 s: " + clock.Read());
Thread.Sleep(1000);
Console.WriteLine("1 s: " + clock.Read());
Thread.Sleep(1000);
Console.WriteLine("2 s: " + clock.Read());
Thread.Sleep(1600);
Console.WriteLine("3.6 s: " + clock.Read());
public interface IClock
{
int Read();
}
public class Clock : IClock
{
private int runs;
[Cache(0, 0, 3)]
public int Read() => ++runs;
}0 s: 1
1 s: 1
2 s: 1
3.6 s: 2
With a sliding lifetime the read at 2 seconds would have kept the entry until 5 seconds; it expired at 3.
Other lifetimes, from the source and the constructor table in the API reference: zero or less keeps nothing; a lifetime that would pass DateTimeOffset.MaxValue (long.MaxValue ticks, TimeSpan.MaxValue) never expires; one before DateTimeOffset.MinValue keeps nothing. "Never expires" is not shown by a run, since no run can wait that long. MemoryCache may also drop entries early under memory pressure; that is System.Runtime.Caching's own behaviour and was not tested.
using System;
using System.Threading.Tasks;
using Autofac;
using CachingServiceWithAOP;
using CachingServiceWithAOP.Extensions;
var builder = new ContainerBuilder();
builder.RegisterCachingModule();
builder.RegisterType<Results>().As<IResults>().SingleInstance().EnableCacheInterception();
using var container = builder.Build();
var results = container.Resolve<IResults>();
Console.WriteLine(results.Nothing() ?? "null");
Console.WriteLine(results.Nothing() ?? "null");
results.Act();
results.Act();
for (var i = 0; i < 2; i++)
{
try
{
results.Fails();
}
catch (InvalidOperationException e)
{
Console.WriteLine("caught: " + e.Message);
}
}
Console.WriteLine(await results.Later());
Console.WriteLine(await results.Later());
Console.WriteLine(await results.Now());
Console.WriteLine(await results.Now());
public interface IResults
{
string? Nothing();
void Act();
int Fails();
Task<int> Later();
ValueTask<int> Now();
}
public class Results : IResults
{
[Cache]
public string? Nothing()
{
Console.WriteLine("Nothing ran");
return null;
}
[Cache]
public void Act() => Console.WriteLine("Act ran");
[Cache]
public int Fails()
{
Console.WriteLine("Fails ran");
throw new InvalidOperationException("no stock");
}
[Cache]
public async Task<int> Later()
{
Console.WriteLine("Later ran");
await Task.Yield();
return 1;
}
[Cache]
public ValueTask<int> Now()
{
Console.WriteLine("Now ran");
return new ValueTask<int>(1);
}
}Nothing ran
null
null
Act ran
Act ran
Fails ran
caught: no stock
Fails ran
caught: no stock
Later ran
1
1
Now ran
1
Now ran
1
- A null result is kept (1.0.1 threw
ArgumentNullExceptioninstead). - A
voidmethod runs every time. - An exception reaches the caller unchanged and is not kept: the next call runs again.
- A
Taskis kept as the task object, so later callers await the same task. - A
ValueTaskis never kept, since aValueTaskmay be awaited only once.
A task that fails is dropped once it fails, so the next call runs again:
using System;
using System.Threading.Tasks;
using Autofac;
using CachingServiceWithAOP;
using CachingServiceWithAOP.Extensions;
var builder = new ContainerBuilder();
builder.RegisterCachingModule();
builder.RegisterType<Orders>().As<IOrders>().SingleInstance().EnableCacheInterception();
using var container = builder.Build();
var orders = container.Resolve<IOrders>();
for (var i = 0; i < 3; i++)
{
try
{
Console.WriteLine(await orders.Load(7));
}
catch (TimeoutException e)
{
Console.WriteLine("caught: " + e.Message);
}
}
public interface IOrders
{
Task<string> Load(int id);
}
public class Orders : IOrders
{
private int runs;
[Cache]
public async Task<string> Load(int id)
{
await Task.Yield();
runs++;
if (runs == 1)
{
throw new TimeoutException("database busy");
}
return "order " + id + " (run " + runs + ")";
}
}caught: database busy
order 7 (run 2)
order 7 (run 2)
The same goes for a cancelled task (from the source; not shown). While a task is still running, every caller gets that running task.
Callers that arrive with the same key while the method is running wait for it, and the method runs once:
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Autofac;
using CachingServiceWithAOP;
using CachingServiceWithAOP.Extensions;
var builder = new ContainerBuilder();
builder.RegisterCachingModule();
builder.RegisterType<Report>().As<IReport>().EnableCacheInterception();
using var container = builder.Build();
var report = container.Resolve<IReport>();
var tasks = Enumerable.Range(0, 16).Select(_ => Task.Run(() => report.Build("2026-09"))).ToArray();
Task.WaitAll(tasks);
Console.WriteLine(string.Join(", ", tasks.Select(t => t.Result).Distinct()));
Console.WriteLine("runs: " + Report.Runs);
public interface IReport
{
string Build(string month);
}
public class Report : IReport
{
public static int Runs;
[Cache]
public string Build(string month)
{
Interlocked.Increment(ref Runs);
Thread.Sleep(200);
return "report for " + month;
}
}report for 2026-09
runs: 1
The lock is per key, so calls with different keys run in parallel. 1.0.1 took no lock for intercepted calls, and every concurrent caller ran the method.
MemoryCacheService.Get(key, func) follows the same rules for keys you choose: a null result is kept, concurrent callers with one key run func once, and a value of another type under the key counts as a miss and is replaced (API reference). A cached null answers a caller asking for a reference type; a caller asking for a value type (Get<int>) treats it as a miss (the repository's tests; not shown here). Set(key, value) stores with the service's lifetime and throws on a null value.
It has no method that removes an entry before it expires, never reads [Cache] from an interface, never intercepts a call made inside the class (this.Method()), cannot intercept a component registered as its class, and never touches the network or the disk. Errors and edge cases shows each of these.
This wiki describes CachingServiceWithAOPSupport 2.0.0 and was last updated on 2026-09-30. The library is MIT licensed. Report problems in the issues. On NuGet: CachingServiceWithAOPSupport.