The rule is one line, and violating it produces a bug that survives every test
because it needs a second request to appear.
lifetime instances safe to depend on typical use
---------- ------------------------ ----------------------- --------------------
Singleton 1 per application Singleton only caches, HttpClient
factories, options
Scoped 1 per HTTP request Scoped, Singleton DbContext, unit of
work, request user
Transient 1 per resolution all three stateless helpers,
cheap services
Rule: a service may depend only on lifetimes at least as long as its own.
The violation has a name — the captive dependency — and it is silent:
public sealed class ReportCache
{
private readonly AppDbContext _db;
public ReportCache(AppDbContext db) => _db = db;
}
Request 1 container creates ReportCache, injects DbContext #1, scope ends,
DbContext #1 is disposed
Request 2 ReportCache still holds DbContext #1
-> ObjectDisposedException: Cannot access a disposed context
Or worse, if nothing disposed it: one DbContext shared by every request,
change-tracking every entity ever loaded, not thread-safe, growing forever.
Both outcomes are bad and the second is worse, because it does not throw. A
DbContext accumulates tracked entities, so a captured one becomes a memory
leak and a source of stale reads, and concurrent use produces corruption rather
than an exception.
The fix is to inject a factory rather than the instance, so the scope is created
where the work happens:
public ReportCache(IServiceScopeFactory scopes) => _scopes = scopes;
public async Task<Report> BuildAsync()
{
using var scope = _scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await BuildFrom(db);
}
The detail worth volunteering is that this class of bug is detectable at
start-up: ValidateScopes, which is on by default in the Development
environment, makes the container throw when a singleton resolves a scoped
service. Running with it enabled in a test turns a production
ObjectDisposedException into a failing build.