Dependency injection lifetimes
Skill Sarmkadan/dotnet-senior-skills/skills/dependency-injection-lifetimes
Senior-level .NET review rules for AI coding agents - Claude Code skills, Cursor rules, and Copilot instructions from one source
npx -y skills add Sarmkadan/dotnet-senior-skills --skill dependency-injection-lifetimesAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 26 days oldThe repository was created 26 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Review .NET dependency injection registrations for captive dependencies, scoped-in-singleton bugs, IServiceProvider abuse, disposal issues, and HttpClient registration. Use when writing or reviewing DI container registrations or constructor injection.
SKILL.md
4.6 KB, as published. Nobody here has run it
Dependency Injection Lifetimes
The one rule that causes 90% of DI bugs
A service must not depend on anything with a SHORTER lifetime. Singleton -> Scoped is the captive dependency: the singleton captures the first scope's instance forever.
// non-compiling: illustrative
// WRONG: singleton captures a scoped DbContext
services.AddSingleton<ICacheWarmer, CacheWarmer>(); // ctor takes AppDbContext
Symptoms in production: ObjectDisposedException: Cannot access a disposed context, cross-request data bleed, "second request returns stale data". The default container validates this only when ValidateScopes is on - which is Development-only by default. Turn it on everywhere; the check is cheap:
builder.Host.UseDefaultServiceProvider(o => { o.ValidateScopes = true; o.ValidateOnBuild = true; });
ValidateOnBuild also catches missing registrations at startup instead of first-request.
Consuming scoped services from singletons (the right way)
Background services and singletons that need scoped services create a scope per unit of work:
public class OutboxProcessor(IServiceScopeFactory scopeFactory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// one batch = one scope = one DbContext
}
}
}
One scope per iteration/batch, not one for the service lifetime (that recreates the captive bug manually) and not one per row (context churn).
Choosing lifetimes
- Scoped: anything stateful per request -
DbContext, unit of work, current-user accessors, most application services by default. - Singleton: stateless and thread-safe - options, clients designed for it (
HttpClientvia factory handlers, most SDK clients like blob/queue clients), caches, pure policy objects. "Stateless" must be verified: a privateList<T>field written in a method makes a singleton a race condition. - Transient: cheap, stateless, and needed with fresh state per injection. Beware: transient
IDisposableresolved from the ROOT provider is tracked until app shutdown - a slow leak. Transients belong in scopes.
When unsure between scoped and transient, pick scoped; when unsure between scoped and singleton, pick scoped. Promotion to singleton is an optimization done with proof of thread safety.
IServiceProvider abuse
Injecting IServiceProvider and calling GetService inside business code is the service-locator anti-pattern: dependencies become invisible to callers and tests, and ValidateOnBuild cannot see them. Legitimate uses only: scope factories in singletons (above), factories resolving by runtime key, framework extension points. On .NET 8+, keyed services ([FromKeyedServices("sms")] INotifier notifier) remove most factory cases.
Related smells:
- Resolving services inside a constructor via provider then storing them - just inject them.
IHttpContextAccessordeep in domain logic - wrap in anICurrentUserabstraction registered scoped.- Constructor doing real work (I/O, opening connections): constructors run at resolution time, sometimes at startup in surprising order. Constructors assign fields; work happens in methods.
HttpClient registration
Never new HttpClient() per request (socket exhaustion) and never one static forever (DNS changes ignored). Use the factory:
services.AddHttpClient<IGitHubApi, GitHubApi>(c => c.BaseAddress = new Uri("https://api.github.com"))
.AddStandardResilienceHandler(); // Microsoft.Extensions.Http.Resilience
Typed clients are transient - do not inject a typed client into a singleton (captive again; inject IHttpClientFactory there instead).
Registration hygiene
- Multiple registrations of the same interface: last one wins for single injection, all resolve for
IEnumerable<T>.TryAddScopedin library/extension methods so consumers can override. - Disposal: the container disposes what it CREATES. Instances you register (
AddSingleton(new Thing())) are yours to dispose. - Assembly-scanning auto-registration hides lifetime decisions; if you use it, pin non-default lifetimes explicitly and audit them in review - a scanner that registers a stateful class as singleton fails silently.