Skip to content

Latest commit

 

History

History
688 lines (464 loc) · 21.5 KB

File metadata and controls

688 lines (464 loc) · 21.5 KB

Rule Reference

This document mirrors the full rule guidance in the repository README so the same guidance is available in both places.

For the latest full rule content, see:


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 No
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 No
DI013 Implementation type mismatch Error No
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

DI001: Service Scope Not Disposed

What it catches: IServiceScope instances created with CreateScope() or CreateAsyncScope() that are never disposed.

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 scopes declared before a later using (scope) disposal block and the same patterns inside constructors, accessors, local functions, lambdas, and anonymous methods.

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 and acknowledgement options where direct refactor is not safe).


DI003: Captive Dependency

What it catches: singleton services capturing scoped or transient dependencies, including constructor injection and high-confidence factory paths such as inline delegates, method-group factories, keyed resolutions, and ActivatorUtilities.CreateInstance(...) without explicit constructor arguments.

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

Code Fix: Yes. Rewrites explicit registration lifetimes when the registration syntax is local and unambiguous (for example AddSingleton, keyed AddKeyedSingleton, 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 from a predeclared scope variable later disposed 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: No. Usually needs manual refactor.


DI005: Use CreateAsyncScope in Async Methods

What it catches: CreateScope() used in async flows where async disposal is needed.

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 scope creation/disposal pattern.


DI006: Static IServiceProvider Cache

What it catches: IServiceProvider / IServiceScopeFactory / keyed provider stored in static fields or properties.

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!;
}

Better pattern:

public sealed class Locator
{
    private readonly IServiceProvider _provider;

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

Code Fix: Yes. Removes static modifier in common cases.


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.


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

Code Fix: Yes. Suggests safer lifetime alternatives.


DI009: Open Generic Captive Dependency

What it catches: open generic singleton registrations that depend on shorter-lived services, including common registration-shape variants such as 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.

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 constructor(s) the container could realistically activate instead of every accessible constructor. It also covers straightforward factory registrations that directly return new MyService(...) or ActivatorUtilities.CreateInstance<MyService>(sp), while staying conservative on more 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: Yes. Adds a missing self-binding registration only when DI015 can prove a single direct constructor dependency is a concrete, non-keyed type and the registration call is local and unambiguous.

Fixable case:

public sealed class MissingDependency { }

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

services.AddScoped<IMyService, MyService>();

DI015 can offer:

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

Not auto-fixable: abstractions/interfaces, keyed dependencies, multiple missing dependencies, transitive-only missing leaves, ServiceDescriptor registrations, and factory-rooted diagnostics.

Known exceptions in this rule: factory-style types, middleware Invoke/InvokeAsync paths, hosted services, and endpoint filter factories.


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.

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: No.


DI013: Implementation Type Mismatch

What it catches: invalid typeof service/implementation pairs that compile but fail at runtime.

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: No.


DI014: Root Service Provider Not Disposed

What it catches: root providers from BuildServiceProvider() that are never disposed.

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 where safe.


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: No.

DI015 strict mode

By default, DI015 assumes common host-provided framework services (logging/options/configuration) are available. 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.
  • 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.

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).

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.
  • 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.

DI017: Circular Dependency

What it catches: constructor-injection cycles such as A -> B -> A, including longer transitive loops.

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, 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, and static classes used as implementation types.

Better pattern:

public sealed class GoodConcreteService : IMyService { }

services.AddSingleton<IMyService, GoodConcreteService>();

Code Fix: No.