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

Why it matters

A store held by a singleton or a static field lives as long as the process. Keyed by a user id, tenant id, or correlation id, it accumulates one entry per distinct caller forever: memory climbs monotonically and the process eventually dies of OutOfMemoryException, typically days into a deployment — which makes it one of the hardest leaks to attribute. IMemoryCache is not automatically safer: with no expiration, no entry size, and no configured SizeLimit it is an unbounded dictionary with extra steps.

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.

Repo sample extraction

Examples pulled from the sample app

Open full sample file

Sample app unbounded-cache note

    public class Bad_UnboundedTenantCache
    {
        private readonly ConcurrentDictionary<string, Report> _cache =
            new ConcurrentDictionary<string, Report>();

        public Report Get(string tenantId)
        {
            // [DI030] Unbounded key space, and nothing in this type ever removes from the store or
            // caps its size, so it grows for the life of the process.
            return _cache.GetOrAdd(tenantId, id => Load(id));
        }

        private Report Load(string id) => new Report { Body = id };
    }

Sample app bounded-by-eviction pattern

    public class Good_BoundedByEvictionOnRelease
    {
        private readonly ConcurrentDictionary<string, Report> _cache =
            new ConcurrentDictionary<string, Report>();

        public Report Get(string tenantId) =>
            _cache.GetOrAdd(tenantId, id => new Report { Body = id });

        // An explicit eviction path tied to the lifetime of what the entry describes.
        public void Release(string tenantId) => _cache.TryRemove(tenantId, out _);
    }

Related guides

  • No problem-guide pages point here yet.

Nearby diagnostics

Other rules in this family

All 37 rules