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.
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.
The generator fully qualifies type names with global::. Those prefixes are omitted
from the listings below for readability.
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:
// <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.
The service-collection file is always emitted, even when the assembly declares no caches — so an AddXxxCaches() that compiles but registers nothing is a strong hint that your [CacheFor<>] class was skipped. A cache class in the global namespace produces a file name with a doubled dot, IC..GetValueCache.g.cs; harmless, but startling when you browse generated output.
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
Addrather thanTryAdd— callingAddXxxCaches()twice registers duplicates. Owned<>is registered as an open generic Singleton viaTryAdd, so if you register your ownOwned<>implementation-or-lifetime before callingAddXxxCaches(), 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.
Your cache class is a Singleton. Any dependency you add to it is captured for the lifetime of the
application. Scoped dependencies belong on the handler, where the per-execution scope will
supply them. The generated constructor takes only IMemoryCache and Owned<…>; if you genuinely
need something more, declare a constructor in your own partial that chains to : base(memoryCache, ownedHandler) and accept only singleton-safe dependencies.
The execution path
A GetValue call runs:
TransformKey(request)produces the key.IMemoryCache.TryGetValue(key)— a hit refreshes any sliding expiration.- On a miss, under a lock, an entry is created with
GetCacheEntryOptions()and populated with an internal per-key state object. - That object owns a single
TaskCompletionSourceandCancellationTokenSource: the first caller starts the handler on the thread pool, later callers await the same task. Owned<IHandler<…>>.GetScopecreates the scope,HandleAsyncruns, the scope is disposed.- 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.