DI024

Hosted Service Creates Scope Outside Execution Loop

Two tiers. First, a BackgroundService.ExecuteAsync override or IHostedService/IHostedLifecycleService start method that creates an IServiceScope once before its long-running execution loop (while (!token.IsCancellationRequested), compound cancellation conditions, while (true), for (;;), System.Threading.PeriodicTimer.WaitForNextTickAsync loops (same-named custom timer methods do not qualify), and channel-consumer loops — await foreach over ChannelReader<T>.ReadAllAsync(...) or while (await reader.WaitToReadAsync(...)), including channel loops nested inside an outer cancellation loop when the scope is created per outer iteration but spans the unbounded inner drain; ConfigureAwait(...)/WithCancellation(...) wrappers on any of the awaited shapes are peeled before gating) and uses it inside the loop — directly, through a service resolved from it before the loop, or through a provider alias local (var sp = scope.ServiceProvider;) used inside the loop. The same helper-local analysis follows one-hop, directly invoked private helpers declared on the same type; field candidates stay confined to true hosted entry points. Generic resolutions and the framework's direct-typeof(T) non-generic GetService/GetRequiredService forms participate, including keyed GetKeyedService/GetRequiredKeyedService calls whose service key is compile-time known, plus casted and null-forgiving results; runtime Type values, dynamic keys, and user-defined same-named methods remain unproven. Compound conditions are evaluated conservatively: nested ! operators are reduced by polarity, every && operand must be long-running because any operand can bound the loop, while one long-running || operand is sufficient; negated cancellation combinations use De Morgan semantics. Declare-then-assign locals (IServiceScope? scope = null; try { scope = factory.CreateScope(); while (...) ... } finally { scope?.Dispose(); } — the try/finally ownership pattern) qualify via their pre-loop assignment: the last direct pre-loop write wins, so a creation makes the candidate and a null/default clear (or an unrecognized value) kills it. Second, a service whose effective registration is provably scoped, resolved once before the loop from any provider and reused across iterations. Both tiers also cover fields: a scope (or resolved service) stored in a field qualifies when every assignment to the field is the expected shape and every assignment site is a field initializer, a constructor, or a hosted execution method (BackgroundService.StartAsync overrides included); partial types are analyzed across all declarations. Reported at the CreateScope/CreateAsyncScope or service-resolution call with the loop as an additional location.

Warning Default severity · Code fix: No

Why it matters

The hosted-service idiom is scope per iteration. A hoisted scope keeps the same scoped instances alive for the process lifetime: an EF Core DbContext serves stale data and its change tracker grows without bound, and one failed iteration poisons all subsequent ones.

README problem example

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

README better pattern

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

No. The exact timer-type boundary and manual-repair contract are recorded in docs/adversarial/DI024.md. Moving the scope into the loop body is a statement-level rewrite with disposal implications; apply it manually.

Guardrails

When DI024 stays silent

Scopes created inside the loop (including inner batch loops reusing the outer iteration's scope), startup scopes consumed entirely before the loop, dispose-and-recreate scopes reassigned inside the loop, hoisted scopes whose every resolution is provably singleton (including keyed singletons matched by compile-time key), bounded loops (including cancellation-plus-counter conjunctions, plain foreach batches, and await foreach over non-channel sources — a repository-style ReadAllAsync is a bounded enumeration, so only System.Threading.Channels.ChannelReader<T> sources qualify), shutdown paths (StopAsync and the stopping/stopped lifecycle callbacks), hoisted services with unprovable lifetimes, fields assigned anywhere outside field initializers/constructors/execution methods (a helper method may reassign per iteration), locals whose closest pre-loop write is a null/default clear, dynamic keyed resolutions, uncalled or deferred helpers, transitive and cross-declaration helpers, helper parameter/field flow, and provider aliases repointed inside the loop all stay silent.

Repo sample extraction

Examples pulled from the sample app

Open full sample file

Sample app hoisted-scope warning

    public class Bad_HoistedScopePollingService : BackgroundService
    {
        private readonly IServiceScopeFactory _scopeFactory;

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

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            // [DI024] Scope is created outside the execution loop of 'ExecuteAsync'
            await using var scope = _scopeFactory.CreateAsyncScope();
            while (!stoppingToken.IsCancellationRequested)
            {
                var processor = scope.ServiceProvider.GetRequiredService<IOrderProcessor>();
                await processor.ProcessPendingAsync(stoppingToken);
                await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
            }
        }
    }

Sample app scope-per-iteration pattern

    public class Good_ScopePerIterationPollingService : BackgroundService
    {
        private readonly IServiceScopeFactory _scopeFactory;

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

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                // Each iteration gets fresh scoped services.
                await using var scope = _scopeFactory.CreateAsyncScope();
                var processor = scope.ServiceProvider.GetRequiredService<IOrderProcessor>();
                await processor.ProcessPendingAsync(stoppingToken);
                await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
            }
        }
    }

Related guides

  • No problem-guide pages point here yet.

Nearby diagnostics

Other rules in this family

All 37 rules