DI030
Unbounded Singleton Or Static Cache
Two shapes of a store that never shrinks. Unbounded growth — a private field of a concrete mutable collection (ConcurrentDictionary<,>, Dictionary<,>, List<>, HashSet<>, Queue<>, ConcurrentBag<>, ConcurrentQueue<>) that is static or owned by a singleton-registered service, written on a per-invocation path with a key derived from request input, where nothing in the declaring type ever removes, clears, drains, or size-checks it. Unbounded cache entries — an IMemoryCache.Set / GetOrCreate / CreateEntry call with an unbounded key and neither an expiration nor a Size, in a compilation whose cache has no SizeLimit.
Info Default severity · Code fix: No
README problem example
services.AddSingleton<PriceService>();
public class PriceService
{
private readonly ConcurrentDictionary<string, Quote> _cache = new();
public Quote Get(string userId) =>
_cache.GetOrAdd(userId, id => Load(id)); // DI030: unbounded, never evicted
private Quote Load(string id) => new();
}
README better pattern
public class PriceService
{
private readonly IMemoryCache _cache;
public PriceService(IMemoryCache cache) => _cache = cache;
public Quote Get(string userId) =>
_cache.GetOrCreate(userId, entry =>
{
entry.SlidingExpiration = TimeSpan.FromMinutes(10);
return Load(userId);
})!;
private Quote Load(string id) => new();
}
No — and none planned. There is no single correct eviction policy: LRU, a TTL, a size cap, or a documented decision that the key space really is bounded are all valid answers, and a fixer that silently picks one would be worse than the diagnostic.
Guardrails
When DI030 stays silent
Reported at Info, because a key space that is unbounded in the type system may be bounded in production. The "never evicted" proof is sound rather than heuristic: the field must be private, so every reference to it lives inside the declaring type and a complete scan of that type is a complete proof. Anything not recognized as a write or a pure read makes the candidate silent — a Remove/TryRemove/Clear/Dequeue/Pop, any read of Count or Length (a size cap), the field passed as an argument, reassigned, iterated with foreach, used in LINQ, or captured into a lambda (a background eviction timer). Bounded keys are excluded up front: any compile-time constant, and any enum, bool, System.Type, or char key. One-time initialization is excluded too — constructor, static-constructor and initializer writes, assembly and Enum.GetValues scans, Lazy<> factories, and one-shot flag guards. Interface-typed fields (IDictionary<,>) stay silent because the backing type may be frozen or capped, as do ImmutableDictionary/FrozenDictionary, lock registries (SemaphoreSlim, Lazy<>, Task, Mutex-shaped value types), non-private fields, and types registered both singleton and scoped. Shapes owned by other rules are excluded rather than duplicated: a scope-resolved value stored into a collection is DI002, a scoped service cached by a singleton is DI003, and a static dictionary of providers is DI006. For IMemoryCache, options built anywhere other than inline at the call site stay silent, and a compilation-wide MemoryCacheOptions.SizeLimit disables the tier entirely. Two editorconfig knobs are available: dotnet_code_quality.DI030.allowed_cache_types and dotnet_code_quality.DI030.detect_memory_cache_bounds. Accepted false negatives: a key reached through a local alias, non-private and static-property caches, multi-level nested dictionaries, and collection types outside the seven recognized generics.