Skip to content

How it works

What the generator emits, why caches are registered as Singletons, and how Owned mints a fresh DI scope for each handler execution.

1 min read

Immediate.Cache is an incremental source generator plus a small runtime library. Nothing is discovered by reflection; see How source generation works for the shared mechanics.

What the generator emits

The generator looks for classes attributed with Immediate.Cache.Shared.CacheForAttribute`1, skips any that are static or nested, reads the target handler’s single Handle/HandleAsync method for its request and response types, and emits two kinds of file.

One file per cache, named IC.{Namespace}.{ClassName}.g.cs:

IC.Dummy.GetUsersQueryCache.g.cs
// <auto-generated />
#nullable enable

#pragma warning disable CS1591

namespace Dummy;

partial class GetUsersQueryCache : Immediate.Cache.Shared.ApplicationCache<
	Dummy.GetUsersQuery.Query,
	Dummy.GetUsersQuery.Response
>
{
	public GetUsersQueryCache(
		Microsoft.Extensions.Caching.Memory.IMemoryCache memoryCache,
		Immediate.Cache.Shared.Owned<
			Immediate.Handlers.Shared.IHandler<
				Dummy.GetUsersQuery.Query,
				Dummy.GetUsersQuery.Response
			>
		> ownedHandler
	) : base(memoryCache, ownedHandler)
	{
	}
}

That is the entire generated cache: a base type and a constructor. All behavior comes from ApplicationCache<,>.

One file per assembly, named IC.ServiceCollectionExtensions.g.cs, holding a public static partial class CacheServiceCollectionExtensions in the project’s root namespace with a single Add{Identifier}Caches method. The identifier is derived from the assembly name or from [assembly: ImmediateAssemblyIdentifier]; see The assembly identifier.

Registration

services.Add(ServiceDescriptor.Singleton(typeof(GetValueCache), typeof(GetValueCache)));

services.TryAdd(ServiceDescriptor.Singleton(typeof(Owned<>), typeof(Owned<>)));

Two things are worth noticing.

  • Each cache is registered by its concrete type only, as a Singleton, with Add rather than TryAdd — calling AddXxxCaches() twice registers duplicates.
  • Owned<> is registered as an open generic Singleton via TryAdd, so if you register your own Owned<> implementation-or-lifetime before calling AddXxxCaches(), yours is kept.

Why Singleton, and what Owned<T> is for

A cache must be a Singleton: its whole job is to hold values, and coalescing concurrent callers onto one handler execution only works if every caller reaches the same instance. Handlers, meanwhile, are registered Scoped by default by AddXxxHandlers(), because they typically depend on scoped services such as a DbContext.

A Singleton cannot consume a Scoped service — the container will refuse (or, worse, captively hold one for the life of the app). Owned<T> resolves that tension. Instead of injecting the handler, the cache injects Owned<IHandler<TRequest, TResponse>>, a Singleton-safe factory over IServiceScopeFactory. Each handler execution does:

var scope = handler.GetScope(out var service);
await using (scope.ConfigureAwait(false))
{
	var response = await service.HandleAsync(request, token).ConfigureAwait(false);

}

A fresh DI scope is created, the handler is rooted in it, and the scope is disposed deterministically when the execution finishes — so the scoped DbContext used by the handler is created and disposed per execution, exactly as it would be for an HTTP request.

The execution path

A GetValue call runs:

  1. TransformKey(request) produces the key.
  2. IMemoryCache.TryGetValue(key) — a hit refreshes any sliding expiration.
  3. On a miss, under a lock, an entry is created with GetCacheEntryOptions() and populated with an internal per-key state object.
  4. That object owns a single TaskCompletionSource and CancellationTokenSource: the first caller starts the handler on the thread pool, later callers await the same task.
  5. Owned<IHandler<…>>.GetScope creates the scope, HandleAsync runs, the scope is disposed.
  6. The result completes the shared task, and every waiter observes it.

Because step 5 resolves IHandler<TRequest, TResponse>, it enters Immediate.Handlers’ generated Handler class — which means the entire behavior pipeline runs inside the cache miss. Logging, validation and transaction behaviors execute on misses only; a cache hit never touches them. Keep that in mind for behaviors you expect to run on every request.

Analyzer and generator packaging

The package ships the generator and analyzers twice — a Roslyn 4.8 build and a Roslyn 5.0 build — and NuGet selects between them based on the Roslyn version in your SDK, not on your target framework. The runtime library (Immediate.Cache.Shared.dll) targets net8.0, net9.0 and net10.0.