DependencyInjection.Lifetime.Analyzers 3.5.8

There is a newer version of this package available.
See the version list below for details.
dotnet add package DependencyInjection.Lifetime.Analyzers --version 3.5.8
                    
NuGet\Install-Package DependencyInjection.Lifetime.Analyzers -Version 3.5.8
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="DependencyInjection.Lifetime.Analyzers" Version="3.5.8">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="DependencyInjection.Lifetime.Analyzers" Version="3.5.8" />
                    
Directory.Packages.props
<PackageReference Include="DependencyInjection.Lifetime.Analyzers">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add DependencyInjection.Lifetime.Analyzers --version 3.5.8
                    
#r "nuget: DependencyInjection.Lifetime.Analyzers, 3.5.8"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package DependencyInjection.Lifetime.Analyzers@3.5.8
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=DependencyInjection.Lifetime.Analyzers&version=3.5.8
                    
Install as a Cake Addin
#tool nuget:?package=DependencyInjection.Lifetime.Analyzers&version=3.5.8
                    
Install as a Cake Tool

<p align="center"> <img src="https://raw.githubusercontent.com/georgepwall1991/DependencyInjection.Lifetime.Analyzers/main/icon.png" width="96" height="96" alt="DependencyInjection.Lifetime.Analyzers icon — Roslyn DI lifetime, captive dependency, and scope leak analyzer for ASP.NET Core"> </p>

DependencyInjection.Lifetime.Analyzers

Compile-time dependency injection lifetime analyzer for .NET — a Roslyn package that catches captive dependencies, DI scope leaks, BuildServiceProvider() misuse, circular dependencies, and unresolvable services in Microsoft.Extensions.DependencyInjection before they fail at runtime.

NuGet NuGet Downloads License: MIT CI Coverage

Searchable docs site · Rule index · Problem guides · NuGet package

Stop shipping DI lifetime bugs that only show up as ObjectDisposedException, stale singletons, or production-only activation failures.

The problem

Microsoft.Extensions.DependencyInjection compiles cleanly even when lifetimes are wrong. A singleton that captures a scoped DbContext, an IServiceScope that is never disposed, BuildServiceProvider() called while registering services, a scoped service resolved from the root provider, or a missing registration often fails only at runtime — in a flaky test, under load, or on a cold production start.

Code review and runtime helpers miss what static analysis can prove from registrations, constructors, and scope usage. Teams using ASP.NET Core, worker services, and the generic host need those failures in the editor and CI, not after deploy.

What it catches

DependencyInjection.Lifetime.Analyzers reports high-confidence DI lifetime and activation bugs:

  • Captive dependencies — singleton (or other long-lived) services capturing scoped or transient dependencies (DI003, DI009)
  • Scope leaks and disposal — undisposed IServiceScope / root providers, use-after-dispose, async scope misuse (DI001, DI004, DI005, DI014)
  • Scope escape and root resolution — scoped services leaving their scope or resolved from the root provider (DI002, DI019)
  • Registration and activation failures — unresolvable dependencies, implementation mismatches, non-instantiable types, circular graphs (DI013, DI015, DI017, DI018)
  • BuildServiceProvider() misuse during service composition (DI016)
  • Service locator drift — static provider caches and overuse of IServiceProvider (DI006, DI007, DI011)
  • ASP.NET Core / host hazards — middleware scoped capture, hosted-service scope-per-iteration, HttpClient lifetime, fire-and-forget scope/HttpContext capture (DI020, DI023, DI024, DI029, DI034)
  • Concurrency and leak families — shared non-thread-safe services, event/Rx/callback subscription leaks, unbounded singleton caches (DI021DI022, DI025DI028, DI030, DI035)

When the analyzer cannot prove a bug statically, it stays quiet. High-signal feedback, not noisy guesses.

Install

Install from NuGet:

dotnet add package DependencyInjection.Lifetime.Analyzers --version 3.5.8

Or add a package reference directly:

<PackageReference Include="DependencyInjection.Lifetime.Analyzers" Version="3.5.8">
  <PrivateAssets>all</PrivateAssets>
</PackageReference>

For Central Package Management (Directory.Packages.props):

<PackageVersion Include="DependencyInjection.Lifetime.Analyzers" Version="3.5.8" />

Then reference it from the project file:

<PackageReference Include="DependencyInjection.Lifetime.Analyzers" PrivateAssets="all" />

No runtime dependency is added to your app. The package is a development-time Roslyn analyzer (PrivateAssets="all").

Optional starter severities in .editorconfig:

[*.cs]
dotnet_diagnostic.DI003.severity = error
dotnet_diagnostic.DI013.severity = error
dotnet_diagnostic.DI007.severity = suggestion
dotnet_diagnostic.DI011.severity = suggestion

By default, runtime-failure and leak-oriented rules stay at Warning or Error, while broader design-smell rules such as DI007, DI010, DI011, and DI012 default to Info to reduce noise. For a rollout checklist, see docs/ADOPTION.md.

See it work

Product-flow diagrams from the real SampleApp build (DI001, DI003, DI014, DI016, and related diagnostics):

1. Build / IDE diagnostics (DI lifetime)

DependencyInjection.Lifetime.Analyzers Roslyn warnings for captive dependency DI003, scope leak DI001, BuildServiceProvider DI016, and root provider DI014

2. Before / after (captive dependency fix)

Before and after: singleton captive dependency on scoped service fixed with IServiceScopeFactory pattern for DI003

3. Product loop — install, diagnose, fix, CI

DependencyInjection.Lifetime.Analyzers product loop: NuGet package install, editor and build diagnostics, code fixes, and CI with the same DI lifetime rules

30-second path

  1. Reference the package with PrivateAssets="all" (version 3.5.2 above).
  2. Keep your existing Microsoft.Extensions.DependencyInjection registrations — no runtime API changes required.
  3. Build in the IDE or with dotnet build (analyzers run in CI when enabled for your host).
  4. Fix any DI00x / DI0xx warnings (many have code fixes for disposal and lifetime corrections).
  5. Raise severities for the rules your team treats as ship blockers (see docs/ADOPTION.md).

Feature snapshot

Area What this analyzer does
Captive dependencies Flags longer-lived services that capture shorter-lived dependencies, including open generics and high-confidence factories.
Scope disposal Detects undisposed IServiceScope / root providers and use-after-dispose patterns.
Lifetime graph Follows registrations, keyed services, and known framework scopes (for example EF Core / options snapshot paths) where modeled.
Activation validity Reports unresolvable, non-instantiable, and circular constructor graphs when the container graph is visible.
BuildServiceProvider Warns on registration-time provider builds and undisposed root providers.
Service locator Surfaces static provider caches and direct IServiceProvider injection smells at low default noise.
ASP.NET Core / host Middleware, hosted services, HttpClient, fire-and-forget scope/HttpContext capture.
Code fixes Safe, unambiguous fixes for selected disposal and lifetime issues (see Rule Index).
Signal quality Stays quiet when the bug cannot be proven statically.

Compatibility

  • Target: projects using Microsoft.Extensions.DependencyInjection (ASP.NET Core, generic host, workers, console, libraries that wire the default container).
  • Analyzer host: Roslyn in Visual Studio, Rider, and dotnet build / CI.
  • Package TFM: netstandard2.0 analyzer assembly (consumes from modern .NET SDK projects).
  • Language: C#.
  • Not claimed: third-party containers (Autofac, DryIoc, Lamar, etc.) unless they use the same registration APIs in a way this analyzer can see; FluentValidation and non-DI frameworks are out of scope.

Who this is for

  • Teams using Microsoft.Extensions.DependencyInjection in ASP.NET Core or generic-host applications.
  • Libraries and internal platforms that want DI usage guarded in CI, not only at runtime.
  • Codebases using factories, keyed services, ActivatorUtilities, or manual scopes.
  • Maintainers reducing IServiceProvider-driven service locator drift over time.

Table of Contents

Rule Index

ID Title Default Severity Code Fix
DI001 Service scope not disposed Warning Yes
DI002 Scoped service escapes scope Warning Yes
DI003 Captive dependency Warning Yes
DI004 Service used after scope disposed Warning Yes
DI005 Use CreateAsyncScope in async methods Warning Yes
DI006 Static IServiceProvider cache Warning Yes
DI007 Service locator anti-pattern Info No
DI008 Disposable transient service Warning Yes
DI009 Open generic captive dependency Warning Yes
DI010 Constructor over-injection Info No
DI011 IServiceProvider injection Info No
DI012 Conditional/duplicate registration misuse Info Yes
DI013 Implementation type mismatch Error Yes
DI014 Root provider not disposed Warning Yes
DI015 Unresolvable dependency Warning Yes
DI016 BuildServiceProvider misuse during registration Warning No
DI017 Circular dependency Warning No
DI018 Non-instantiable implementation type Warning No
DI019 Scoped service resolved from root provider Warning Yes
DI020 Middleware captures scoped service in constructor Warning No
DI021 Non-thread-safe service shared across concurrent handler invocations Warning Yes
DI022 Service instance reused across handler invocations Info Yes
DI023 Fire-and-forget background work captures a scope Warning No
DI024 Hosted service creates scope outside execution loop Warning No
DI025 Event subscription on longer-lived publisher without unsubscribe Warning Yes
DI026 Event subscription on scoped publisher without unsubscribe Info Yes
DI027 Rx subscription on longer-lived observable without dispose Warning No
DI028 Discarded callback registration on a longer-lived source Warning No
DI029 HttpClient lifetime misuse Warning No
DI030 Unbounded singleton or static cache Info No
DI031 Shared implementation registered under several service types Info No
DI032 Service implements only IAsyncDisposable Warning No
DI033 Container will not dispose a pre-built instance Info No
DI034 HttpContext used in fire-and-forget background work Warning No
DI035 Non-thread-safe service shared across a fan-out Warning No

DI001: Service Scope Not Disposed

What it catches: IServiceScope instances created with CreateScope() or CreateAsyncScope() that are never disposed, including scopes whose only disposal call is hidden behind a conditional branch, switch section, loop, catch block, or after a branch exit that can bypass shared cleanup. DI001 recognizes predeclared nullable scope locals assigned conditionally when a later conditional-access, non-null-guarded, same-branch pre-exit, or finally disposal reliably closes ownership, and it treats directly returned scopes as caller-owned even through simple casts or conditional return arms. Reassignment leaks and loop-created scopes that need per-iteration disposal still report.

Why it matters: undisposed scopes can retain scoped and transient disposable services longer than expected, causing memory and handle leaks.

Explain Like I'm Ten: If you borrow a paintbrush and never wash it, it dries out and ruins the next project.

Problem:

public void Process()
{
    var scope = _scopeFactory.CreateScope();
    var svc = scope.ServiceProvider.GetRequiredService<IMyService>();
    svc.Run();
}

Better pattern:

public void Process()
{
    using var scope = _scopeFactory.CreateScope();
    var svc = scope.ServiceProvider.GetRequiredService<IMyService>();
    svc.Run();
}

Code Fix: Yes. Adds using / await using where possible.


DI002: Scoped Service Escapes Scope

What it catches: a service resolved from a scope that is returned or stored somewhere longer-lived, including services resolved through provider aliases, delegates that capture scoped services and then escape, scopes disposed later via using (scope), and the same patterns inside constructors, accessors, local functions, lambdas, and anonymous methods. It also detects wrapped returned resolutions and later-returned locals such as casts, as casts, null-forgiving, ternary/coalesce expressions, and non-generic GetService(typeof(T)), while keeping pre-resolution locals and proven non-escaping scope-local holder objects, including simple direct local holder aliases, quiet. Holders that later escape through a return, conditional-access slot return, long-lived assignment including null-conditional assignment to a field/property-held receiver, nested receiver path under a fresh wrapper, escaping delegate, returned/stored local container, already-escaped local collection, returned collection alias, or ??= receiver that may still point at a long-lived holder still report; slot reads before the scoped write stay quiet.

Why it matters: once the scope is disposed, that service may point to disposed state.

Explain Like I'm Ten: It is like taking an ice cube out of the freezer for later; by the time you need it, it has melted.

Problem:

public IMyService GetService()
{
    using var scope = _scopeFactory.CreateScope();
    return scope.ServiceProvider.GetRequiredService<IMyService>();
}

Better pattern:

public void UseServiceNow()
{
    using var scope = _scopeFactory.CreateScope();
    var service = scope.ServiceProvider.GetRequiredService<IMyService>();
    service.Execute();
}

Code Fix: Yes (suppression option for intentionally accepted cases where direct refactoring is not practical).


DI003: Captive Dependency

What it catches: singleton services capturing scoped or transient dependencies, including constructor injection, IEnumerable<T> collection captures, known scoped framework services such as IOptionsSnapshot<T>, EF Core contexts and DbContextOptions<TContext> registrations from AddDbContext(...), AddDbContextFactory(...), AddDbContextPool(...), and AddPooledDbContextFactory(...) including service/implementation overload self-registrations, and high-confidence factory paths such as inline delegates, stable local delegate factories, method-group factories, GetServices<T>(), keyed resolutions, and ActivatorUtilities.CreateInstance(...) calls where DI still resolves a scoped or transient constructor parameter.

ServiceDescriptor registrations prepended with services.Insert(0, descriptor) are analyzed too, including reordered named arguments, concrete framework ServiceCollection receivers, and repeated prepends whose runtime list precedence differs from source order. Nonzero or dynamic insert indexes and source-defined concrete Insert bodies stay conservative because their absolute position or mutation behavior is not provable.

Why it matters: lifetime mismatch can produce stale state, leaks, and thread-safety defects.

Explain Like I'm Ten: If one pupil keeps the shared class scissors all term, nobody else can use them when needed.

Problem:

services.AddScoped<IScopedService, ScopedService>();
services.AddSingleton<ISingletonService, SingletonService>();

public sealed class SingletonService : ISingletonService
{
    public SingletonService(IScopedService scoped) { }
}

Better pattern:

services.AddScoped<ISingletonService, SingletonService>();

// or keep singleton and create scopes inside operations
public sealed class SingletonService : ISingletonService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public SingletonService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public void Run()
    {
        using var scope = _scopeFactory.CreateScope();
        var scoped = scope.ServiceProvider.GetRequiredService<IScopedService>();
        scoped.DoWork();
    }
}

DbContext-backed processors:

services.AddDbContext<AppDbContext>();
services.AddScoped<IProcessor, Processor>();
services.AddHostedService<ProcessorHostedService>();

public sealed class ProcessorHostedService : IHostedService
{
    private readonly IServiceProvider _serviceProvider;

    public ProcessorHostedService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        using var scope = _serviceProvider.CreateScope();
        var processor = scope.ServiceProvider.GetRequiredService<IProcessor>();
        await processor.RunAsync(cancellationToken);
    }

    public Task StopAsync(CancellationToken cancellationToken) =>
        Task.CompletedTask;
}

Repository and unit-of-work abstractions are reported when their registered lifetime is scoped or transient. DI003 does not infer DbContext-backed behavior from names like IRepository<T> or IUnitOfWork alone.

Code Fix: Yes. Rewrites explicit registration lifetimes when the registration syntax is local and unambiguous (for example AddSingleton, TryAddSingleton, keyed AddKeyedSingleton, inline factory registrations, and supported ServiceDescriptor forms).


DI004: Service Used After Scope Disposed

What it catches: using a service after the scope that produced it has already ended, including services resolved through provider aliases, scoped collections from GetServices<T>() enumerated after disposal, explicit Dispose() / DisposeAsync() (including scope?.Dispose() for scope locals), wrapped use receivers such as service!.DoWork() and ((IService)service).DoWork(), scopes disposed later via using (scope), and the same patterns inside constructors, accessors, local functions, lambdas, and anonymous methods.

Why it matters: leads to runtime disposal errors and brittle service behaviour.

Explain Like I'm Ten: It is like trying to turn on a torch after you removed the batteries.

Problem:

IMyService service;
using (var scope = _scopeFactory.CreateScope())
{
    service = scope.ServiceProvider.GetRequiredService<IMyService>();
}
service.DoWork();

Better pattern:

using (var scope = _scopeFactory.CreateScope())
{
    var service = scope.ServiceProvider.GetRequiredService<IMyService>();
    service.DoWork();
}

Code Fix: Yes. Moves simple immediate invocation-style uses back into the owning scope only when the diagnostic local was assigned in that scope, or adds a narrow pragma suppression for context-dependent cases.


DI005: Use CreateAsyncScope in Async Methods

What it catches: CreateScope() used in async flows where async disposal is needed and CreateAsyncScope() is available, including async methods, lambdas, local functions, anonymous methods, and top-level programs that use await. Detection covers regular member access (_scopeFactory.CreateScope()), parameterless IServiceScope CreateScope() methods on concrete IServiceScopeFactory implementations, and conditional-access receivers (_scopeFactory?.CreateScope(), _provider?.CreateScope()) alike.

Why it matters: async disposables (IAsyncDisposable) may not be cleaned up correctly with sync disposal patterns.

Explain Like I'm Ten: If a machine needs a proper shutdown button, pulling the plug is not enough.

Problem:

public async Task RunAsync()
{
    using var scope = _scopeFactory.CreateScope();
    var service = scope.ServiceProvider.GetRequiredService<IMyService>();
    await service.ExecuteAsync();
}

Better pattern:

public async Task RunAsync()
{
    await using var scope = _scopeFactory.CreateAsyncScope();
    var service = scope.ServiceProvider.GetRequiredService<IMyService>();
    await service.ExecuteAsync();
}

Code Fix: Yes. Rewrites safe using scope creation/disposal patterns to await using plus CreateAsyncScope(), including explicit IServiceScope declarations that must become var for AsyncServiceScope.


DI006: Static IServiceProvider Cache

What it catches: IServiceProvider / IServiceScopeFactory / keyed provider stored in static fields or properties, including common wrappers, mutable/immutable/frozen dictionary value caches, recursive dictionary values, and simple holder types that only wrap a provider.

Why it matters: global provider state encourages service locator use and muddles lifetime boundaries.

Explain Like I'm Ten: Leaving the school master key in the corridor means anybody can open any door at any time.

Problem:

public static class Locator
{
    public static IServiceProvider Provider { get; set; } = null!;
    private static readonly Lazy<IServiceProvider> LazyProvider = new(() => Provider);
    private static readonly Dictionary<string, Lazy<IServiceProvider>> TenantProviders = new();
    private static readonly ImmutableDictionary<string, IServiceProvider> SnapshotProviders = ImmutableDictionary<string, IServiceProvider>.Empty;
}

Better pattern:

public sealed class Locator
{
    private readonly IServiceProvider _provider;

    public Locator(IServiceProvider provider)
    {
        _provider = provider;
    }
}

Code Fix: Yes. Removes static modifier in common private-member cases where existing references stay valid; it is suppressed for nested-type references, type-qualified references, and instance field/property initializers that would become invalid instance-member access.


DI007: Service Locator Anti-Pattern

What it catches: resolving dependencies via IServiceProvider inside app logic.

Why it matters: hides real dependencies, makes tests harder, and weakens architecture boundaries.

Explain Like I'm Ten: If every meal starts with "search the kitchen and see what turns up", dinner becomes chaos.

Problem:

public sealed class MyService
{
    private readonly IServiceProvider _provider;

    public MyService(IServiceProvider provider)
    {
        _provider = provider;
    }

    public void Run()
    {
        var dep = _provider.GetRequiredService<IDependency>();
        dep.Execute();
    }
}

Better pattern:

public sealed class MyService
{
    private readonly IDependency _dependency;

    public MyService(IDependency dependency)
    {
        _dependency = dependency;
    }

    public void Run() => _dependency.Execute();
}

Code Fix: No. This is usually architectural refactoring.

DI007 follows generic resolutions, direct typeof(...) arguments, and local Type aliases initialized from typeof(...) when they are not reassigned before the resolution call. It stays quiet in recognized composition/factory boundaries: DI registration factories, value-returning Create*/Build* factory methods, ASP.NET Core middleware Invoke/InvokeAsync methods whose first parameter is HttpContext, BackgroundService.ExecuteAsync, exact hosted-service lifecycle implementations, options configure/validate implementations, and provider-aware options/factory delegates.


DI008: Disposable Transient Service

What it catches: transient services implementing IDisposable/IAsyncDisposable in risky patterns.

Why it matters: disposal ownership can become unclear and resources may be leaked.

Explain Like I'm Ten: Borrowing a bike every minute without returning the old one fills the whole bike shed.

Problem:

services.AddTransient<IMyService, DisposableService>();

public sealed class DisposableService : IMyService, IDisposable
{
    public void Dispose() { }
}

Better pattern:

services.AddScoped<IMyService, DisposableService>();
// or ensure explicit disposal ownership if transient is intentional

DI008 follows generic, typeof(...), keyed, named-argument, ServiceDescriptor.Transient(...), ServiceDescriptor.Describe(..., ServiceLifetime.Transient), new ServiceDescriptor(..., ServiceLifetime.Transient), conditional services?.Add(ServiceDescriptor.Transient(...)), TryAddTransient, and TryAddEnumerable registration shapes. Factory registrations stay quiet because disposal ownership is explicit in user code.

Code Fix: Yes. Suggests safer lifetime alternatives and rewrites local descriptor lifetime arguments where the registration is unambiguous.


DI009: Open Generic Captive Dependency

What it catches: open generic singleton registrations that depend on shorter-lived services, including TryAddSingleton(...), ServiceDescriptor.Singleton(...), keyed open-generic singleton registrations, and IEnumerable<T> constructor captures where the element service is shorter-lived.

Why it matters: every closed generic instance inherits the lifetime mismatch.

Explain Like I'm Ten: If the recipe is wrong at the top of the cookbook, every dish made from it comes out wrong.

Problem:

services.AddScoped<IScopedService, ScopedService>();
services.AddSingleton(typeof(IRepository<>), typeof(Repository<>));

public sealed class Repository<T> : IRepository<T>
{
    public Repository(IScopedService scoped) { }
}

Better pattern:

services.AddScoped(typeof(IRepository<>), typeof(Repository<>));

DI009 follows the single likely activation constructor the container can actually use. Optional/default-value parameters are treated as activatable during that selection, and ambiguous equally-greedy constructor sets stay silent instead of guessing.

Dependency lifetimes are looked up against user registrations first and then fall back to the shared known-framework classifier, so open-generic singletons that capture IOptionsSnapshot<T> are reported as scoped captures even when the application does not register Options manually. IOptions<T> and IOptionsMonitor<T> keep their singleton lifetime and stay quiet.

Code Fix: Yes. Can adjust lifetime for open generic registrations.


DI010: Constructor Over-Injection

What it catches: constructors with too many meaningful dependencies.

Why it matters: often signals a class with too many responsibilities.

Explain Like I'm Ten: If one backpack needs ten straps to carry, it is probably trying to hold too much at once.

Problem:

public sealed class ReportingService
{
    public ReportingService(
        IDep1 dep1,
        IDep2 dep2,
        IDep3 dep3,
        IDep4 dep4,
        IDep5 dep5)
    {
    }
}

Better pattern: split into focused collaborators and inject smaller abstractions.

For normal type registrations, DI010 evaluates the public constructor(s) the container could realistically activate instead of every declared constructor. It also covers straightforward factory registrations that directly return new MyService(...), final-return factory blocks that set up locals before return new MyService(...), and ActivatorUtilities.CreateInstance<MyService>(sp), while staying conservative on branching or dynamic factories.

By default, DI010 reports when a constructor has more than 4 meaningful dependencies. It ignores primitives/value types, optional parameters, provider-plumbing types already covered by DI011, and common framework abstractions such as ILogger<T>, IOptions<T>, and IConfiguration.

Configure the threshold in .editorconfig:

[*.cs]
dotnet_code_quality.DI010.max_dependencies = 5

Code Fix: No. Design decision required.


DI011: IServiceProvider Injection

What it catches: constructor injection of IServiceProvider, IServiceScopeFactory, or IKeyedServiceProvider in normal services.

Why it matters: this commonly enables hidden runtime resolution and service locator behaviour.

Explain Like I'm Ten: Asking for a giant "surprise box" each time instead of a known tool means no one knows what you actually need.

Problem:

public sealed class MyService
{
    public MyService(IServiceProvider provider) { }
}

Better pattern: inject concrete dependencies directly.

Code Fix: No. Replacing provider plumbing with explicit dependencies is a design decision.

Known exceptions in this rule: factory-style types with value-returning factory members, singleton services that use IServiceScopeFactory to create scopes deliberately, ASP.NET Core middleware Invoke/InvokeAsync methods whose first parameter is HttpContext, hosted services, endpoint filter factories, and provider parameters on non-public constructors the container cannot activate.


DI012: Conditional Registration Misuse

What it catches:

  • TryAdd* calls after an Add* already registered that service.
  • Duplicate Add* registrations where later entries override earlier ones.

DI012 also follows the same IServiceCollection flow across local aliases and source-defined helper/local-function wrappers, while treating opaque helper boundaries conservatively instead of guessing at registration order. It stays quiet for intentional branch-dependent fallbacks such as guarded Add* plus unconditional TryAdd*, and for mutually exclusive if/else if/else alternative registrations. When a Replace(...) still leaves a duplicate descriptor behind, DI012 reports the active registration that survives the single-descriptor replacement, ignoring inactive TryAdd* calls when choosing the message location.

Why it matters: registration intent becomes unclear and behaviour differs from what readers expect.

Explain Like I'm Ten: Writing your name on the same seat twice does not get you two seats; one note just replaces the other.

Problem:

services.AddSingleton<IMyService, ServiceA>();
services.TryAddSingleton<IMyService, ServiceB>(); // ignored

services.AddSingleton<IMyService, ServiceA>();
services.AddSingleton<IMyService, ServiceB>(); // overrides A

Better pattern: decide and signal intent clearly: TryAdd* first, or explicit override with comments/tests.

Code Fix: Yes for ignored TryAdd* and TryAddKeyed* calls that are block-contained standalone statements; the fixer removes the redundant ignored registration. Duplicate override cases and embedded single-line statement bodies remain manual.


DI013: Implementation Type Mismatch

What it catches: invalid service/implementation pairs that compile but fail at runtime, including generic, typeof(...), keyed, named-argument, and ServiceDescriptor registrations.

Why it matters: service activation throws at runtime (ArgumentException/InvalidOperationException depending on path).

Explain Like I'm Ten: A round plug will not fit a square socket just because both are on your desk.

Problem:

public interface IRepository { }
public sealed class WrongType { }

services.AddSingleton(typeof(IRepository), typeof(WrongType));

Better pattern:

public sealed class SqlRepository : IRepository { }
services.AddSingleton(typeof(IRepository), typeof(SqlRepository));

Code Fix: Yes. Offers broad assists where the syntax and symbols are local enough to rewrite safely: remove the invalid block-contained standalone registration, replace the implementation type with a compatible candidate, or retarget the service type to an interface/base type implemented by the current implementation, including invalid implementation-instance registrations. Embedded single-line statement bodies stay manual unless a symbol-backed type rewrite is available.


DI014: Root Service Provider Not Disposed

What it catches: root providers from BuildServiceProvider() that are never disposed, including local providers whose only manual disposal is conditional, catch-only, after reassignment to another provider, or after repeated creation inside a loop. Straight-line explicit disposal, standard Dispose() to Dispose(true) cleanup, and caller-owned return flows are accepted even when the BuildServiceProvider() result is parenthesized, same-instance cast, null-forgiven, selected by a ternary arm, or supplied by a null-coalescing operand; user-defined conversions remain reportable because they may produce a different instance.

Why it matters: singleton disposables at root scope may never be cleaned up.

Explain Like I'm Ten: Locking the front door but leaving all the taps running still wastes the whole house.

Problem:

var services = new ServiceCollection();
var provider = services.BuildServiceProvider();
var service = provider.GetRequiredService<IMyService>();

Better pattern:

using var provider = services.BuildServiceProvider();
var service = provider.GetRequiredService<IMyService>();

Code Fix: Yes. Adds disposal pattern for simple local declarations with no existing manual disposal code. Conditional or otherwise partial manual-disposal flows stay diagnostic-only so the ownership rewrite remains deliberate.


DI015: Unresolvable Dependency

What it catches: registered services with direct or transitive constructor/factory dependencies that are not registered (including keyed and open-generic paths).

Why it matters: runtime activation fails when DI tries to create the service.

Explain Like I'm Ten: Planning to build a kite without string means the build fails when you start.

Problem:

public interface IMissingDependency { }
public interface IMyService { }

public sealed class MyService : IMyService
{
    public MyService(IMissingDependency missing) { }
}

services.AddSingleton<IMyService, MyService>();

Better pattern:

public sealed class MissingDependency : IMissingDependency { }

services.AddScoped<IMissingDependency, MissingDependency>();
services.AddSingleton<IMyService, MyService>();

Code Fix: Yes. Adds a missing self-binding registration when DI015 can prove a single direct concrete class dependency is safe to register. Supports local constructor diagnostics, TryAdd* registration sites, local IServiceCollection aliases, direct GetRequiredService<TConcrete>() factory diagnostics, and keyed self-bindings when the key can be emitted as a C# literal.

DI015 strict mode

By default, DI015 assumes common host-provided framework services are available, including logging/options/configuration, ILoggerFactory, IHostApplicationLifetime, and the Generic Host's singleton IHostLifetime. Strict mode still requires explicit registrations, keyed framework-service requests are never satisfied by this unkeyed ambient assumption, and explicit registrations override ambient lifetime classification so scoped framework-service replacements remain visible to lifetime rules. Explicit framework extension calls such as AddHttpClient(), AddMemoryCache(), and AddHttpContextAccessor() are modeled as registrations for IHttpClientFactory, IMemoryCache, and IHttpContextAccessor; those services still report as missing when the matching extension is absent. TimeProvider also reports as missing unless registered explicitly. Typed HTTP client registrations treat one constructor HttpClient parameter as factory-provided while still checking repeated HttpClient parameters and other typed-client constructor dependencies. EF Core contexts registered through AddDbContext(...), AddDbContextFactory(...), AddDbContextPool(...), or AddPooledDbContextFactory(...) are also modeled as registrations, including service/implementation overload self-registrations and the DbContextOptions<TContext> and IDbContextFactory<TContext> dependencies those patterns require. Disable that assumption for stricter analysis:

[*.cs]
dotnet_code_quality.DI015.assume_framework_services_registered = false

DI015 is intentionally conservative to keep false positives low:

  • Source-visible IServiceCollection wrappers are expanded before DI015 reports missing registrations.
  • Stable local delegate factories are inspected, including inherited keyed factory parameters, later definite simple reassignments, exhaustive local-function branch rewrites, and method-group delegate aliases to local functions that rewrite the factory, while unrelated assignment left-hand-side uses and opaque delegate-local writes such as direct delegate calls, delegate .Invoke() calls, and ref/out writes stay conservative.
  • [ServiceKey] parameters, IEnumerable<T>, IServiceProviderIsService, and IServiceProviderIsKeyedService are treated as container-provided.
  • Parameterless [FromKeyedServices] inherits the containing keyed registration key when that key is known.
  • KeyedService.AnyKey keyed registrations satisfy exact keyed dependency requests.
  • Definite same-flow RemoveAll(...) and Replace(...) mutations suppress diagnostics for registrations they remove.
  • Dependency cycles are treated as resolvable.
  • Factory registrations without inspectable dependency paths are treated as resolvable.
  • GetService(...) and dynamic keyed resolutions are treated as optional/unknown.
  • If an earlier opaque or external wrapper could have registered services on the same IServiceCollection flow, DI015 stays silent instead of speculating.
  • If any effective candidate registration is backed by an opaque factory, DI015 stays silent instead of speculating.
  • Two-Type registrations with a non-extractable implementation argument are treated as registered-but-unknown, suppressing downstream missing-registration guesses without inventing an implementation shape.

DI016: BuildServiceProvider Misuse

What it catches: BuildServiceProvider() calls while composing registrations (for example in ConfigureServices, IServiceCollection extension registration methods, registration lambdas, or builder-style .Services helper flows), whether written as reduced extension syntax (services.BuildServiceProvider()) or as a direct static call (ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(builder.Services)).

Why it matters: building a second provider during registration can duplicate singleton instances and produce lifetime inconsistencies.

Explain Like I'm Ten: If you set up a second classroom register halfway through, children can end up counted twice and rules become muddled.

Problem:

public static IServiceCollection AddFeature(this IServiceCollection services)
{
    var provider = services.BuildServiceProvider();
    var options = provider.GetRequiredService<IMyOptions>();
    return services;
}

Better pattern:

public static IServiceCollection AddFeature(this IServiceCollection services, IMyOptions options)
{
    // Use provided dependencies/options without creating a second container
    return services;
}

Code Fix: No.

DI016 is intentionally conservative to reduce false positives:

  • It only reports symbol-confirmed DI BuildServiceProvider() calls in registration contexts.
  • It does not report provider-factory methods that intentionally return IServiceProvider, concrete provider implementations, or awaited provider results.
  • It recognizes assignable IServiceCollection abstractions and same-boundary helper/alias flows from .Services, but it does not warn on standalone top-level new ServiceCollection() composition roots.
  • It recognizes metadata-defined IServiceCollection fluent chains, so builder.Services.AddSingleton<...>().BuildServiceProvider() is treated as the same registration source as builder.Services.BuildServiceProvider().
  • Direct static extension calls recover the receiver from the Roslyn-bound IServiceCollection parameter, so named and reordered arguments retain the same registration-context proof; provider-factory return guardrails still apply.
  • Builder .Services flows wrapped in the null-forgiving operator (builder.Services!) or a same-type cast ((IServiceCollection)builder.Services) at the call site, in helper return expressions, or in local-variable initializers are still recognized as registration contexts, while provider-factory methods that wrap the same expression stay silent because they return IServiceProvider.
  • Identity-preserving null guards such as (builder.Services ?? throw new InvalidOperationException()).BuildServiceProvider() retain the builder .Services proof; a coalesce with an arbitrary fallback collection stays silent because the actual source is not provable.
  • Conditional-access invocations and aliases such as builder.Services?.BuildServiceProvider(), builder?.Services.BuildServiceProvider(), and var services = builder?.Services; services.BuildServiceProvider(); are recognized through the enclosing ConditionalAccessExpression and the MemberBindingExpression-shaped .Services access, so null-safe builder flows participate in detection the same way as direct member access. Provider-factory methods wrapping the same shape stay quiet.

DI017: Circular Dependency

What it catches: constructor-injection cycles such as A -> B -> A, including longer transitive loops. It follows effective registration precedence, including exact closed registrations before open-generic fallbacks, and mirrors the default container's constructor-set rule: the greediest resolvable constructor is analyzed only when its resolved service identifiers (type plus key) contain every other resolvable constructor's service identifiers. Equivalent reordered constructors therefore expose the same real cycle, while non-superset sets stay silent because activation is ambiguous.

Why it matters: the default DI container cannot resolve circular constructor graphs and will fail at runtime when the service is activated.

Explain Like I'm Ten: If two people each wait for the other to hand over the key first, the door never opens.

Problem:

services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IPaymentService, PaymentService>();

public sealed class OrderService : IOrderService
{
    public OrderService(IPaymentService payment) { }
}

public sealed class PaymentService : IPaymentService
{
    public PaymentService(IOrderService order) { }
}

Better pattern: break the cycle by moving shared logic into a third collaborator or by changing the dependency direction so each service has an acyclic constructor graph.

Code Fix: No. Breaking dependency cycles is a design change.


DI018: Non-Instantiable Implementation Type

What it catches: registrations whose implementation type cannot be constructed by the DI container, such as abstract classes, interfaces, static classes, delegate types registered without a factory, default structs and enums, or concrete classes with no public constructors.

Why it matters: these registrations compile, but fail at runtime when the container tries to activate the service.

Explain Like I'm Ten: Writing a ghost on the class register does not mean someone can actually show up for class.

Problem:

public interface IMyService { }
public sealed class BadPrivateCtorService : IMyService
{
    private BadPrivateCtorService() { }
}

services.AddSingleton<IMyService, BadPrivateCtorService>();

DI018 also reports abstract classes, interfaces, static classes, delegate types (such as services.AddSingleton<MyHandler>() where MyHandler is a delegate), default structs, and enums used as implementation types without a factory expression. The default container activates implementation types through public constructors returned by reflection: Roslyn's synthetic value-type constructor is not emitted as constructor metadata, so a default struct or enum fails at first resolution. A struct with an explicitly declared public constructor remains valid. Factory arguments are recognized from the bound delegate parameter even when the expression is an invocation, conditional, coalesce expression, or delegate object creation, so valid factory registrations do not self-bind the service type. Delegates carry only implicit (object, IntPtr) and (object, UIntPtr) constructors that the default DI container cannot populate, so the registration fails at activation.

Better pattern:

public sealed class GoodConcreteService : IMyService { }
public readonly struct MyValueService { }

services.AddSingleton<IMyService, GoodConcreteService>();

// For delegate types, register with a factory expression:
services.AddSingleton<MyHandler>(sp => (msg) => Console.WriteLine(msg));

// Supply value types explicitly instead of asking the container to activate them:
services.AddSingleton(typeof(MyValueService), _ => new MyValueService());

Code Fix: No.


DI019: Scoped Service Resolved From Root Provider

What it catches: scoped services, known scoped framework services such as IOptionsSnapshot<T>, EF Core contexts from AddDbContext(...), AddDbContextFactory(...), AddDbContextPool(...), and AddPooledDbContextFactory(...) including service/implementation overload self-registrations, or services whose activation graph reaches a scoped service, resolved from a root IServiceProvider such as ASP.NET Core app.Services, ASP.NET test-host factory.Services / server.Services, Generic Host host.Services, nullable root-provider surfaces such as app.Services!, or a provider returned by BuildServiceProvider(). Root-provider aliases also stay classified through ?? throw guards and conditional expressions whose two result arms are proven root through path-stable declarations or straight-line writes. Provider declarations and assignments are collected in source order, path stability propagates through copied aliases, later unclassified, ??=, deconstruction, and ref/out writes invalidate older provider facts. Write facts become visible only after right-hand-side, initializer, or argument evaluation, and nested mutation events are processed before their enclosing write, so resolutions and alias copies observe the provider state at that runtime point. Merely binding or retargeting a ref local preserves the referents' facts; source-positioned mappings ensure later writes follow every possible storage active at that point across conditional or unconditional retargeting and ref-conditional local, by-reference argument, or lvalue targets, while reads use only the mapping active at their position and classify the alias only when every possible storage agrees. Writes through aliases with multiple possible referents invalidate every candidate storage rather than claiming each one definitely received the new value. Forward or backward goto edges cannot make path-dependent facts stable. Field/property facts never qualify because source position cannot prove cross-method execution; deferred lambda, LINQ-query, and local-function hazards remain conservative for captured outer storage, while locals and parameters owned by the deferred boundary retain ordinary path stability for declarations and straight-line writes. Control flow outside that owning boundary does not alter the path executed inside it. Other control-flow-dependent, mixed root/scoped, and unknown arms stay conservative. Both ordinary extension syntax and direct static calls through the exact framework ServiceProviderServiceExtensions and ServiceProviderKeyedServiceExtensions types are analyzed, including reordered named arguments; same-named user extensions stay silent.

Why it matters: the default container's scope validation is designed to prevent scoped services from being resolved directly or indirectly from the root provider. Resolving them from root can fail at runtime or accidentally stretch scoped state to application lifetime.

Explain Like I'm Ten: A classroom pass only works for one lesson. Taking it home for the whole year breaks the rules.

Problem:

var app = builder.Build();
var db = app.Services.GetRequiredService<MyDbContext>();

Better pattern:

var app = builder.Build();
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();

DI019 also reports singleton and hosted-service methods that resolve scoped services from an injected root provider.

Shows the full resolution path. When a scoped service is reached indirectly, DI019 names every hop on the way down, so you never have to trace the graph by hand to find out why an innocent-looking resolution is unsafe:

DI019: Service 'OrderProcessor' resolves scoped dependency from the root provider:
       OrderProcessor -> IInvoiceBuilder -> IRepository -> AppDbContext

That is strictly more actionable than the container's own ValidateOnBuild exception, which reports only the two endpoints and leaves the chain in between for you to reconstruct.

Code Fix: Yes. Offers to wrap ordinary extension-form resolutions in a using declaration or block with a new scope. Direct static-call syntax reports without a code fix because rewriting the declaring type as a provider receiver would not compile.


DI020: Middleware Captures Scoped Service In Constructor

What it catches: Scoped services captured by the constructor of a conventional middleware class — both directly (a scoped parameter) and transitively (a parameter whose activation graph reaches a scoped service). Middleware registrations are recognized in reduced extension form (app.UseMiddleware<T>()) and in direct framework static form (UseMiddlewareExtensions.UseMiddleware<T>(app) / UseMiddlewareExtensions.UseMiddleware(app, typeof(T))), with explicit activation arguments matched to constructor parameters.

Why it matters: Conventional middleware (used via app.UseMiddleware<T>()) is instantiated once per application lifetime. Injecting a scoped service into the constructor will cause that specific scoped instance to be captured for the entire application lifetime, which often leads to "captive dependency" bugs or runtime errors (e.g., if the service is a DbContext).

Problem:

public class MyMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IMyScopedService _scoped;

    public MyMiddleware(RequestDelegate next, IMyScopedService scoped)
    {
        _next = next;
        _scoped = scoped; // Scoped service captured in singleton middleware!
    }

    public Task InvokeAsync(HttpContext context) => _next(context);
}

Better pattern:

public class MyMiddleware
{
    private readonly RequestDelegate _next;

    public MyMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    // Resolve scoped services from InvokeAsync parameters
    public Task InvokeAsync(HttpContext context, IMyScopedService scoped)
    {
        return _next(context);
    }
}

Code Fix: No. Moving dependencies to the InvokeAsync method may require significant architectural changes.


DI021: Non-Thread-Safe Service Shared Across Concurrent Handler Invocations

What it catches: A documented non-thread-safe service (EF Core DbContext and derived contexts, DbConnection/DbCommand/DbTransaction/DbDataReader and their interfaces, IDbContextTransaction, HttpContext) that is created or resolved once and then captured — through a field, a closure over an outer local, or an enclosing method parameter — into a handler that a framework invokes concurrently:

  • ServiceBusProcessor.ProcessMessageAsync / ProcessErrorAsync (when MaxConcurrentCalls is provably above 1)
  • ServiceBusSessionProcessor.ProcessMessageAsync / ProcessErrorAsync (sessions are pumped concurrently by default)
  • EventProcessorClient.ProcessEventAsync / ProcessErrorAsync (partitions are processed concurrently)
  • RabbitMQ EventingBasicConsumer.Received / AsyncEventingBasicConsumer.Received / ReceivedAsync (instance-correlated: the consumer's own factory/connection/channel chain proves ConsumerDispatchConcurrency — proven 1 or a fresh default factory stays silent, proven above 1 warns, untraceable chains stay config-gated DI022; fallback constants must bind to the real RabbitMQ property)
  • System.Threading.Timer callbacks with a finite period (callbacks can overlap)
  • System.Timers.Timer.Elapsed (elapsed events can overlap unless AutoReset = false or a SynchronizingObject is set)
  • Parallel.For / ForEach / ForEachAsync / Invoke bodies
  • PLINQ ForAll bodies (partitions run concurrently unless WithDegreeOfParallelism(1) is proven on the query chain)
  • TPL Dataflow ActionBlock / TransformBlock / TransformManyBlock delegates (when MaxDegreeOfParallelism is provably above 1; blocks default to sequential)
  • EventProcessor<TPartition> batch and error overrides (partitions are processed concurrently)

It also catches the deferred variant: resolving from a long-lived scope captured from outside the handler (_scope.ServiceProvider.GetRequiredService<AppDbContext>() inside the handler still hands the same instance to every concurrent invocation).

Why it matters: This is the deferred form of the captive dependency. The lifetimes can look correct — a scope was even created — but one instance is shared across overlapping invocations and fails at runtime with errors like "A second operation was started on this context instance before a previous operation completed." It works in dev (one message at a time) and explodes under production load.

Problem:

public class OrderProcessor : BackgroundService
{
    private readonly AppDbContext _db;                 // resolved once
    private readonly ServiceBusSessionProcessor _processor;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _processor.ProcessMessageAsync += HandleAsync; // invoked concurrently
        await _processor.StartProcessingAsync(stoppingToken);
    }

    private async Task HandleAsync(ProcessSessionMessageEventArgs args)
    {
        _db.Add(args);                 // DI021: one DbContext, N concurrent handlers
        await _db.SaveChangesAsync();  // "A second operation was started on this context"
    }
}

Better pattern:

private async Task HandleAsync(ProcessSessionMessageEventArgs args)
{
    await using var scope = _scopeFactory.CreateAsyncScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    db.Add(args);
    await db.SaveChangesAsync();
}

DI021 stays quiet for handlers that already do the right thing: a scope created inside the handler, IDbContextFactory<TContext> usage, instances created inline, and handlers that explicitly serialize themselves (lock on a stable monitor shared from outside the handler, SemaphoreSlim wait/release in try/finally, Interlocked/Monitor.TryEnter reentrancy guards, timer re-arm). Locking the handler's own parameter, an object created inside the handler, or a shared monitor reassigned by the handler does not serialize separate invocations and still reports. Frameworks that already create a scope per message (MassTransit, NServiceBus, Quartz, Hangfire, SignalR, Azure Functions) are deliberately not sinks.

Code Fix: Yes. Rewrites the handler to resolve the service from a new scope per invocation, plumbs IServiceScopeFactory through the constructor when needed, and removes the now-dead captured field.


DI022: Service Instance Reused Across Handler Invocations

What it catches: The same capture shape as DI021, but on a sink whose concurrency is controlled by a configuration knob that cannot be proven at compile time — canonically ServiceBusProcessor where MaxConcurrentCalls comes from configuration or is left at its default.

Why it matters: If the knob is ever raised above 1 this becomes the DI021 concurrency crash. Even with sequential dispatch, one instance accumulates state across all messages: an EF Core change tracker grows without bound, and a failed SaveChanges poisons every subsequent message. DI022 reports at Info severity because the concurrency claim is conditional; teams that want it louder can raise it with one line:

dotnet_diagnostic.DI022.severity = warning

When MaxConcurrentCalls is a compile-time constant greater than 1, the diagnostic upgrades to DI021 (Warning). When it is provably 1, both rules stay silent.

Code Fix: Yes. Same scope-per-invocation rewrite as DI021.


DI024: Hosted Service Creates Scope Outside Execution Loop

What it catches: A BackgroundService.ExecuteAsync override (or IHostedService/IHostedLifecycleService start method) that creates an IServiceScope once before its long-running execution loop — including direct or compound cancellation checks, while (true), for (;;), PeriodicTimer loops, and channel-consumer loops — and uses it inside the loop, either directly or through a service resolved from it. One-hop, directly invoked private helpers in the same type declaration receive the same helper-local analysis; deferred and transitive helper calls stay conservative. Generic and direct-typeof(T) non-generic GetService/GetRequiredService resolutions participate, including keyed GetKeyedService/GetRequiredKeyedService calls with compile-time keys; runtime Type values and dynamic keys stay conservative. Compound conditions stay conservative: nested ! operators are reduced by polarity, every && operand must be long-running, while one long-running || operand is sufficient; negated cancellation combinations use De Morgan semantics. It also catches a service whose registration is provably scoped resolved once before the loop and reused across iterations.

Why it matters: The well-known hosted-service idiom is scope per iteration. A scope hoisted above the loop keeps the same scoped instances alive for the entire process lifetime: an EF Core DbContext serves stale data and its change tracker grows without bound, a unit of work accumulates every iteration's state, and a single failure poisons all subsequent iterations.

public class PollingService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public PollingService(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await using var scope = _scopeFactory.CreateAsyncScope(); // DI024: one scope for the process lifetime
        while (!stoppingToken.IsCancellationRequested)
        {
            var processor = scope.ServiceProvider.GetRequiredService<IOrderProcessor>();
            await processor.ProcessPendingAsync(stoppingToken);
        }
    }
}

Correct pattern: create the scope inside the loop body so each iteration gets fresh scoped services:

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        await using var scope = _scopeFactory.CreateAsyncScope();
        var processor = scope.ServiceProvider.GetRequiredService<IOrderProcessor>();
        await processor.ProcessPendingAsync(stoppingToken);
    }
}

DI024 stays quiet when the code already does the right thing: a scope created inside the loop (including an inner batch loop reusing the outer iteration's scope), a startup scope consumed entirely before the loop (migrations), a dispose-and-recreate scope reassigned inside the loop, a hoisted scope whose every resolution is provably singleton (hoisting is then behaviorally identical), bounded loops such as cancellation-plus-counter conjunctions, and hoisted services whose lifetime cannot be proven scoped from the visible registrations.

Code Fix: No. Moving the scope into the loop is a statement-level rewrite with disposal implications; apply the correct pattern above manually.


DI025: Event Subscription On Longer-Lived Publisher Without Unsubscribe

What it catches: A transient- or scoped-registered service that subscribes (+=) an instance-capturing handler — an instance method group, a this-capturing lambda, or a stored instance-bound delegate field — to an event on a longer-lived publisher and never unsubscribes. Longer-lived publishers are injected dependencies whose registration is provably singleton — closed registrations preferred, open-generic singleton registrations (AddSingleton(typeof(IEventBus<>), typeof(EventBus<>))) matched for constructed injections — via a constructor parameter or a field/property assigned only from a constructor parameter, and static events. Identity and reference casts preserve that proof, so ((IBaseBus)_bus).Changed += H reports for direct injected receivers and already-proven stable chains. Chained receivers (_host.Bus.Changed += H) report too when the publisher is a stable projection of an injected root: the lifetime proof anchors on the chain root's registration, and every intermediate segment must be a readonly field, a get-only auto-property, or a getter returning one — interface segments proven through the root's registered implementation types. C# forbids assigning another type's field-like event, so the cross-type delegate leak lives on a delegate-typed field or property of the publisher instead: _bus.Handlers += OnMessage and the equivalent self-assignment _bus.Handlers = (EventHandler)Delegate.Combine(_bus.Handlers, OnMessage) report identically to an event +=, with a mirrored Delegate.Remove self-assignment recognized as the matching unsubscription. An unsubscription written with a different lambda instance (-= (s, e) => Handle() after += (s, e) => Handle()) is recognized as the classic no-op unsubscribe bug: the subscription still reports, and the diagnostic points at the ineffective -=.

Why it matters: This is the most common managed memory leak in .NET. The publisher's delegate list holds a strong reference to every handler target, so a singleton publisher roots every subscriber instance the container ever creates — each resolution leaks a handler plus the full object graph behind it, and every event raise fans out to thousands of stale handlers executing against released state. Catching it precisely requires knowing registration lifetimes, which is exactly what this analyzer knows and lifetime-blind analyzers cannot.

services.AddSingleton<IMessageBus, MessageBus>();
services.AddTransient<OrderHandler>();

public class OrderHandler
{
    private readonly IMessageBus _bus;

    public OrderHandler(IMessageBus bus)
    {
        _bus = bus;
        _bus.MessageReceived += OnMessage; // DI025: every OrderHandler instance stays rooted by the singleton
    }

    private void OnMessage(object sender, EventArgs e) { }
}

Correct pattern: store the subscription and remove it when the subscriber is released:

public class OrderHandler : IDisposable
{
    private readonly IMessageBus _bus;

    public OrderHandler(IMessageBus bus)
    {
        _bus = bus;
        _bus.MessageReceived += OnMessage;
    }

    public void Dispose() => _bus.MessageReceived -= OnMessage;

    private void OnMessage(object sender, EventArgs e) { }
}

DI025 stays quiet when the pairing is safe or unprovable: singleton subscribers (a bounded population of one cannot grow the delegate list — hosted services subscribing to singleton buses stay silent), transient publishers (scoped publishers report the DI026 Info tier instead), any matching -= anywhere in the type (Dispose, StopAsync, a Detach() teardown, or the unsubscribe-then-resubscribe idiom) with the same method group — override chains normalized — or the same stored delegate field/local, static handlers and this-free lambdas (they do not root the subscriber), publishers assigned from new or from ordinary method parameters, user-defined or value-changing receiver conversions, chained receivers whose projection is not provably stable (a settable or computed segment may hand out a different instance per access, and metadata-only or virtual segments cannot be inspected), unregistered subscriber or publisher types, keyed-only publisher registrations, EventSource-derived publishers, and factory registrations whose implementation type is unknown. Casted and uncast receiver syntax canonicalize to the same publisher identity when matching += with -=.

Code Fix: Yes, in three tiers, all gated on a method-group handler whose receiver (a field/property, a field/property-rooted chain, or a static event) still resolves inside Dispose. (1) Insert into an existing Dispose — when the type already declares a block-bodied Dispose(), Dispose(bool), or DisposeAsync() and implements the matching disposal interface (IDisposable/IAsyncDisposable — a method merely named Dispose is never called by the container), the fix inserts the mirrored -= at the top of that method. (2) Create the Dispose path when the contract is inherited — when disposability comes from a base type following the standard virtual Dispose(bool) pattern, the fix adds a protected override void Dispose(bool disposing) that unsubscribes and calls base.Dispose(disposing); inherited shapes with no such hook (a non-virtual or explicitly-implemented base Dispose) are refused so the fix can never add a method the container won't call. (3) Implement IDisposable for scoped subscribers — a subscriber registered scoped that implements neither disposal interface gets IDisposable plus a public void Dispose() that unsubscribes, since its owning scope disposes it deterministically. Introducing IDisposable on a transient subscriber stays refused — that is the DI008 disposable-transient-capture shape, so the fix never trades a DI025 for a DI008 — and hoisting a lambda into a field stays refused because it changes capture semantics.


DI026: Event Subscription On Scoped Publisher Without Unsubscribe

What it catches: The scope-bounded tier of DI025: a transient-registered service subscribes an instance-capturing handler to an event on a scoped registered publisher — same receiver, identity/reference-cast, handler, and unsubscription proofs as DI025 — and never unsubscribes. The publisher's registration lifetime is resolved with the same rules (most conservative registration wins, closed registrations preferred over open-generic fallbacks, keyed-only registrations excluded), so a publisher registered both scoped and singleton reports DI026, because only the scope-bounded claim is provable.

Why it matters: A transient injected with a scoped publisher is resolved from that same scope, so every transient instance the scope creates stays rooted in the publisher's delegate list until the scope is disposed, and the event keeps invoking handlers on instances the container has already released. In a short per-request scope the accumulation usually dies quickly; in long-lived scopes — SignalR connections, Blazor circuits, hosted-service loop scopes — it is a real leak. DI026 reports at Info because the impact depends on scope longevity; raise it per team policy:

[*.cs]
dotnet_diagnostic.DI026.severity = warning
services.AddScoped<IMessageBus, MessageBus>();
services.AddTransient<OrderHandler>();

public class OrderHandler
{
    public OrderHandler(IMessageBus bus)
    {
        bus.MessageReceived += OnMessage; // DI026: rooted by the scoped bus until the scope is disposed
    }

    private void OnMessage(object sender, EventArgs e) { }
}

Correct pattern: identical to DI025 — store the subscription and remove it with -= when the subscriber is released (for example in Dispose).

DI026 shares every DI025 guardrail: scoped subscribers on scoped publishers stay silent (equal lifetimes are torn down together), any matching -= anywhere in the type suppresses, and all silence-on-unknown legs (unstable chained projections, new-assigned members, keyed-only publishers, EventSource publishers, factory registrations) apply unchanged — stable chained receivers report the tier exactly like direct receivers.

Code Fix: Yes — the same tier-1 (insert into existing Dispose) and tier-2 (override an inherited virtual Dispose(bool)) repairs as DI025, with the same gates. The tier-3 implement-IDisposable assist is never offered here, because DI026 only fires for transient subscribers and making a transient IDisposable is exactly the DI008 shape the fixer refuses.


DI027: Rx Subscription On Longer-Lived Observable Without Dispose

What it catches: The Rx twin of DI025. IObservable<T>.Subscribe(...) returns an IDisposable token that unsubscribes the observer when disposed — there is no -= to prove missing, so the leak proof inverts to a discarded token. A transient or scoped registered service subscribes an instance-capturing handler (method group, this-capturing lambda, or stored delegate) to an observable exposed by a longer-lived publisher — an injected singleton dependency, or a scoped publisher shared by a transient subscriber — and throws the returned token away. The observable is reached through the same classified receivers as DI025 (an injected member proven ctor-assigned, a constructor parameter, or a stable chained projection such as _source.Ticks), and the publisher's registration lifetime is resolved with the same rules (most conservative registration wins, closed registrations preferred over open-generic fallbacks, keyed-only registrations excluded).

Why it matters: A discarded subscription is a live one. The observable holds the observer, the observer captures the subscriber, and nothing ever releases it, so the longer-lived publisher roots every subscriber instance the container creates — leaking memory on each resolution and invoking stale observers against released state. DI027 is a single Warning tier: whether the publisher is singleton or a scope-shared scoped, a discarded token that outlives the subscriber is a definite leak.

services.AddSingleton<ITicker, Ticker>();   // Ticker : IObservable<int>
services.AddTransient<TickHandler>();

public class TickHandler
{
    public TickHandler(ITicker ticker)
    {
        ticker.Subscribe(OnTick); // DI027: the IDisposable is discarded; every TickHandler stays rooted
    }

    private void OnTick(int value) { }
}

Correct pattern: store the token and dispose it when the subscriber is released (for example in Dispose, or via a CompositeDisposable).

public class TickHandler : IDisposable
{
    private readonly IDisposable _subscription;

    public TickHandler(ITicker ticker) => _subscription = ticker.Subscribe(OnTick);

    public void Dispose() => _subscription.Dispose();

    private void OnTick(int value) { }
}

DI027 recognizes both idiomatic receiver syntax (source.Subscribe(handler)) and direct static extension syntax (ObservableExtensions.Subscribe(source, handler)). Static calls must bind to a real extension method; Roslyn's bound parameter mapping identifies the observable even when named arguments are reordered, and the source argument is never mistaken for a handler.

The BCL observer overload is also covered when the subscriber passes itself directly (source.Subscribe(this)): the argument must bind to IObserver<T> and reduce semantically to the containing instance. Separate observer objects remain silent because they do not prove that the registered subscriber is retained.

Guardrails (silent, by design): DI027 only fires on the highest-confidence discard shapes — an ignored expression statement (obs.Subscribe(H);), a discard assignment (_ = obs.Subscribe(H)), a local initialized with the token that is never referenced again (and is not a using declaration), or a simple assignment to a private field declared on the subscriber when that field has no other symbol-bound reference across any partial declaration. A later disposal, return, argument pass, reassignment, or any other field access stays silent; inherited and public/internal/protected fields also stay silent because external handling cannot be ruled out. using/using var, CompositeDisposable/DisposeWith/AddTo/SerialDisposable, and more complex field flows remain conservative. As with DI025, singleton subscribers, transient publishers, scoped-on-scoped pairs, static or this-free lambdas, separate observer objects, unregistered subscriber/publisher types, keyed-only publishers, unstable chained projections, non-extension static helpers named Subscribe, and non-observer Subscribe(this) overloads all stay silent.

Code Fix: No — planned. The safe repair (introduce IDisposable, store the token, dispose it) depends on the subscriber's registered lifetime exactly like the DI025 tier-3 assist, and is deferred to a follow-up.


DI028: Discarded Callback Registration On A Longer-Lived Source

What it catches: The third member of the DI025/DI027 family. Where DI025 proves a missing -= and DI027 proves a discarded Subscribe token, DI028 covers every remaining way .NET hands out a callback registration: IOptionsMonitor<T>.OnChange, CancellationToken.Register / UnsafeRegister, ChangeToken.OnChange, IChangeToken.RegisterChangeCallback, and CancellationTokenSource.CreateLinkedTokenSource. Each returns a registration that detaches the callback when disposed. A transient or scoped registered service registers a callback on a longer-lived source — an injected singleton options monitor, IHostApplicationLifetime.ApplicationStopping, a token from a singleton-held CancellationTokenSource, a configuration reload token — and discards the registration. The callback must provably capture the subscriber, either through the handler (method group, this-capturing lambda, stored delegate) or through the object? state argument.

Why it matters: A discarded registration is a live one. The source holds the callback, the callback captures the subscriber, and nothing detaches it, so the source roots every subscriber instance the container creates — along with everything that subscriber holds, which for a typical service means a DbContext and its change tracker. ApplicationStopping lives for the whole process, so the leak grows once per resolution and is never reclaimed. CancellationToken.Register is the sharpest case: it looks local and cheap, but a registration on a process-lifetime token is permanent.

Problem:

services.AddSingleton<IHostApplicationLifetime, ApplicationLifetime>();
services.AddScoped<OrderProcessor>();

public class OrderProcessor
{
    public OrderProcessor(IHostApplicationLifetime lifetime, AppDbContext db)
    {
        // DI028: the registration is discarded; every OrderProcessor (and its DbContext)
        // stays rooted in the shutdown token for the life of the process.
        lifetime.ApplicationStopping.Register(() => Flush(db));
    }

    private void Flush(AppDbContext db) { }
}

Better pattern: store the registration and dispose it when the subscriber is released.

public class OrderProcessor : IDisposable
{
    private readonly CancellationTokenRegistration _registration;

    public OrderProcessor(IHostApplicationLifetime lifetime, AppDbContext db) =>
        _registration = lifetime.ApplicationStopping.Register(() => Flush(db));

    public void Dispose() => _registration.Dispose();

    private void Flush(AppDbContext db) { }
}

DI028 recognizes both the instance form (monitor.OnChange(h)) and the static extension form (OptionsMonitorExtensions.OnChange(monitor, h)), binding the source through Roslyn's parameter mapping so reordered named arguments are handled. The state overloads are covered too: token.Register(static s => ((Worker)s!).Run(), this) has a static callback yet still pins the subscriber through state, and DI028 reports it.

Guardrails (silent, by design): the subscriber must be registered and shorter-lived than the source — a singleton or hosted-service subscriber registering on ApplicationStopping is the idiomatic, correct pattern and never fires. Method-parameter tokens stay silent (an ASP.NET RequestAborted registration is request-scoped and correct), as do locally created CancellationTokenSource tokens, IOptionsSnapshot sources above scoped subscribers, and any scoped-on-scoped or equal-lifetime pair. Discard proof mirrors DI027 exactly: only an ignored expression statement, a _ = discard, a never-referenced non-using local, or an otherwise-unused private field report. using/using var, a later Dispose, a return, an argument pass (including into a CompositeDisposable), a reassignment, or any other field reference all stay silent. Chained sources are only followed through provably stable projections, and the framework projections CancellationTokenSource.Token and IHostApplicationLifetime.ApplicationStopping/ApplicationStarted/ApplicationStopped are accepted only as a contiguous suffix, so nothing can be laundered through them. Known false negatives: the common var linked = CreateLinkedTokenSource(...); await X(linked.Token); shape stays silent because the local is referenced — proving that case needs DI014-class disposal analysis and is a follow-up; static-held CancellationTokenSource fields, IChangeToken reached through a field or local, and non-trivial ChangeToken.OnChange producer lambdas are also silent.

Code Fix: No — planned. Introducing IDisposable on a transient subscriber recreates the DI008 disposable-transient shape, the linked-source arm needs a different repair from the registration arm, and CancellationTokenRegistration is a struct with defensive-copy pitfalls. Deferred rather than shipped half-correct.


DI029: HttpClient Lifetime Misuse

What it catches: Two opposite lifetime errors on the same connection pool. Socket exhaustion — a registered service constructs new HttpClient(...) on a per-invocation path (a method, an accessor, a lambda, any loop body, or the constructor of a transient service). Stale DNS — an HttpClient is handed to the container as a singleton (AddSingleton<HttpClient>, AddSingleton(new HttpClient()), a singleton ServiceDescriptor, or a keyed singleton) or held in a static field or property.

Why it matters: Each HttpClient constructed per call opens its own connection pool, and disposing it does not free the socket — the connection sits in TIME_WAIT for minutes. Under load the ephemeral port range runs out and the application starts failing with SocketException. Wrapping the construction in using makes it worse, not better, because disposal is exactly what strands the socket. The usual fix — make it a singleton — trades the problem for its mirror image: one handler holds its connections for the life of the process and never re-resolves DNS, so after a failover or deployment the client keeps routing to an endpoint that has moved. IHttpClientFactory is the only shape that gets both connection reuse and DNS freshness, because it pools handlers and retires them on a rotation interval.

Problem:

services.AddScoped<ApiClient>();

public class ApiClient
{
    public async Task<Order> GetAsync(int id)
    {
        // DI029: one socket per call, stranded in TIME_WAIT after disposal.
        using var http = new HttpClient();
        return await http.GetFromJsonAsync<Order>($"/orders/{id}");
    }
}

services.AddSingleton<HttpClient>();  // DI029: handler never rotates, DNS never refreshes

Better pattern: let the container own the handler pool.

services.AddHttpClient<ApiClient>(c => c.BaseAddress = new Uri("https://api.example.com"));

public class ApiClient
{
    private readonly HttpClient _http;

    public ApiClient(HttpClient http) => _http = http;

    public Task<Order> GetAsync(int id) => _http.GetFromJsonAsync<Order>($"/orders/{id}");
}

Guardrails (silent, by design): the socket-exhaustion tier only fires when the containing type is provably a registered implementation in the same compilation, so tests, Program/top-level statements, and unregistered helpers stay silent — a compilation with no registrations reports nothing at all. It also requires IHttpClientFactory to be available, so a diagnostic is never raised where the fix is unavailable. A handler supplied by the caller (a parameter, field, or injected value rather than an inline construction) transfers pool ownership and stays silent, as does disposeHandler: false and any non-constant disposeHandler. Construction in the constructor of a scoped or singleton service outside a loop is an accepted false negative. Handler arguments are bound by parameter symbol rather than position, so named and reordered arguments are handled. A bare handler construction stays silent: constructing HttpClientHandler or SocketsHttpHandler opens no connection until something sends through it, and the leak shape that matters — new HttpClient(new SocketsHttpHandler()) — is already reported through the client. A client whose handler sets PooledConnectionLifetime is also silent at both stale-DNS tiers: that handler retires pooled connections on an interval and re-resolves DNS with them, which is the documented way to run a long-lived client without the factory. The stale-DNS tier is singleton-only: AddTransient<HttpClient> is DI008's disposable-transient finding and DI029 deliberately does not double-report it, and AddScoped<HttpClient> is an accepted false negative. HttpClient subclasses are excluded at both gates, Lazy<HttpClient> and dictionary-of-clients static wrappers are accepted false negatives, and a singleton factory that provably delegates to IHttpClientFactory.CreateClient stays silent. A single construction never produces two findings: static initializers belong to the static-member tier, and a construction passed as an argument belongs to the registration tier.

Code Fix: No — planned. Rewriting new HttpClient() into an injected IHttpClientFactory requires adding services.AddHttpClient() at a registration site that may be in another document or another project, and possibly adding a PackageReference — which a code fix cannot do. Applying only the constructor and call-site half produces code that compiles and then throws InvalidOperationException: No service for type 'IHttpClientFactory', so it is deferred rather than shipped as a fake repair.


DI030: Unbounded Singleton Or Static Cache

What it catches: 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.

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, GC pressure rises with it, 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.

Problem:

services.AddSingleton<PriceService>();

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

    public Quote Get(string userId) =>
        // DI030: unbounded key space, nothing in this type ever removes or caps.
        _cache.GetOrAdd(userId, id => Load(id));

    private Quote Load(string id) => new();
}

Better pattern: bound the store — an expiration, a size limit, or an explicit eviction path tied to the lifetime of what the entry describes.

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();
}

Guardrails (silent, by design): reported at Info, because a key space that is unbounded in the type system may be bounded in production — a dictionary keyed by tenant id in a four-tenant deployment is fine. 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 the analyzer does not recognize as a write or a pure read makes it 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). Eviction in another type is impossible for a private field, and eviction in a lambda or helper silences the report. 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.

Code Fix: 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.


Samples

  • samples/SampleApp: diagnostic examples for DI001 to DI030.
  • samples/DI015InAction: runnable unresolved-dependency demonstration.

Configuration

Suppress one diagnostic in code:

#pragma warning disable DI007
var service = _provider.GetRequiredService<IMyService>();
#pragma warning restore DI007

Or in .editorconfig:

[*.cs]
dotnet_diagnostic.DI007.severity = none

Adoption Guide

  • Start with docs/ADOPTION.md if you are evaluating the package for a team or shared platform.
  • Use docs/RULES.md if you want a rule-by-rule reference you can link from issues, pull requests, or internal docs.

Requirements

  • .NET Standard 2.0 consumer compatibility.
  • Microsoft.Extensions.DependencyInjection.

Known Limitations

  • Compile-time analysis only; runtime registrations cannot be analysed.
  • Cross-assembly registration graphs are not fully tracked.
  • Lifetime inference follows single-service resolution paths and may not model every IEnumerable<T> multi-registration activation path.

Frequently Asked Questions

What is a dependency injection lifetime analyser for C#?

It is a Roslyn analyser package that checks your DI registrations and DI usage during compilation, so lifetime and scope mistakes are found before production.

Can this analyser prevent ASP.NET Core runtime DI failures?

It helps prevent a large class of runtime failures, including captive dependencies, scope disposal mistakes, and unregistered direct/transitive dependencies in constructor and factory activation paths.

Does this work in Rider, Visual Studio, and CI builds?

Yes. It runs in IDE diagnostics and standard dotnet build / CI workflows because it is delivered as a standard .NET analyser package.

Contributing

See CONTRIBUTING.md.

Licence

MIT Licence - see LICENSE.

There are no supported framework assets in this package.

Learn more about Target Frameworks and .NET Standard.

  • .NETStandard 2.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories (1)

Showing the top 1 popular GitHub repositories that depend on DependencyInjection.Lifetime.Analyzers:

Repository Stars
VahidN/DNTCommon.Web.Core
DNTCommon.Web.Core provides common scenarios' solutions for ASP.NET Core applications.
Version Downloads Last Updated
3.7.0 21 7/31/2026
3.6.0 33 7/31/2026
3.5.20 97 7/27/2026
3.5.19 84 7/27/2026
3.5.18 77 7/27/2026
3.5.17 97 7/27/2026
3.5.16 88 7/27/2026
3.5.15 90 7/27/2026
3.5.14 88 7/27/2026
3.5.13 90 7/27/2026
3.5.12 94 7/27/2026
3.5.11 95 7/27/2026
3.5.10 88 7/27/2026
3.5.9 99 7/27/2026
3.5.8 94 7/27/2026
3.5.7 96 7/27/2026
3.5.6 97 7/27/2026
3.5.5 93 7/27/2026
3.5.4 73 7/27/2026
3.5.3 89 7/27/2026
Loading failed

**DI028 static `CancellationTokenSource` callback retention** (false negative) — a discarded