Captive dependencies
A singleton that takes a scoped service keeps the first one forever. How it happens, how the container catches it, and the fixes.
After this lesson you can
- Explain why a singleton's dependencies are resolved from the root
- Spot a captive dependency, including one hidden behind a transient
- Fix one with IServiceScopeFactory or a DbContext factory
A service lives as long as whatever holds a reference to it. When a longer-lived service takes a shorter-lived one in its constructor, the shorter-lived one is captured: it now lives as long as its holder. The case that matters is a singleton that depends on a scoped service.
builder.Services.AddDbContext<ShopDb>(); // scoped
builder.Services.AddSingleton<PriceCache>(); // singleton
public class PriceCache(ShopDb db) { /* ... */ } // captures ShopDb
A singleton is built once, from the root provider, so its
dependencies are resolved from the root too. The ShopDb it receives is
not any request's context; it is one created for the root and never
disposed until shutdown. Every request that touches PriceCache now
shares that one context: its change tracker keeps growing, it returns
stale entities, and two requests using it at once throw because a
DbContext does not support concurrent operations.
The example extends the model container from the last lesson with
constructor dependencies. Orders is scoped, PriceCache is a
singleton, and both take Db.
public class Container{ public readonly Dictionary<string, (string Life, string[] Deps)> Regs = new(); readonly Dictionary<string, string> root = new(); readonly Dictionary<string, int> count = new(); public Dictionary<string, string> NewScope() => new(); public string Resolve(string name, Dictionary<string, string> scope) { var (life, deps) = Regs[name]; if (life == "Singleton") scope = root; // built from the root if (life != "Transient" && scope.TryGetValue(name, out var cached)) return cached; var parts = deps.Select(d => Resolve(d, scope)).ToList(); count[name] = count.GetValueOrDefault(name) + 1; var made = name + "#" + count[name] + (parts.Count > 0 ? "(" + string.Join(",", parts) + ")" : ""); if (life != "Transient") scope[name] = made; return made; }} public static class Solution{ public static List<string> Run() { var c = new Container(); c.Regs["Db"] = ("Scoped", new string[0]); c.Regs["Orders"] = ("Scoped", new[] { "Db" }); c.Regs["PriceCache"] = ("Singleton", new[] { "Db" }); var lines = new List<string>(); for (int request = 1; request <= 3; request++) { var scope = c.NewScope(); lines.Add($"request {request}: {c.Resolve("Orders", scope)} {c.Resolve("PriceCache", scope)}"); } return lines; }}The container can catch it
The default container has two switches. ValidateScopes makes it
refuse to build a singleton that needs a scoped service, and refuse to
resolve a scoped service (or anything that needs one) from the root
provider; the check runs when a service is first resolved.
ValidateOnBuild runs the singleton check for every registration when
the provider is built, so the app fails at startup instead. The default
host turns both on in the Development environment and leaves them off
elsewhere, which is why this bug usually surfaces as an exception on a
developer's machine rather than as stale data in production. The check
follows constructor dependencies through the whole graph, so a
transient between the singleton and the scoped service does not hide
the problem.
Fixes
-
Make the consumer scoped if it holds no state worth sharing.
-
Inject
IServiceScopeFactoryand open a scope for each unit of work:public class PriceCache(IServiceScopeFactory scopes) { public async Task RefreshAsync() { using var scope = scopes.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<ShopDb>(); // ... use db, then the scope disposes it } } -
For EF Core specifically, register
AddDbContextFactory<ShopDb>()and injectIDbContextFactory<ShopDb>; eachCreateDbContext()returns a context the caller owns and disposes.
The second model replaces the scope factory with a small class and calls
Refresh twice.
public class ScopeFactory{ int dbCount; public Scope CreateScope() => new Scope(this); public string NewDb() => "Db#" + ++dbCount;} public class Scope : IDisposable{ readonly ScopeFactory factory; string? db; public List<string> Log = new(); public Scope(ScopeFactory f) => factory = f; public string Db => db ??= factory.NewDb(); // scoped: one per scope public void Dispose() { if (db != null) Log.Add("disposed " + db); }} public class PriceCache(ScopeFactory scopes) // singleton{ public List<string> Log = new(); public void Refresh() { var scope = scopes.CreateScope(); using (scope) { Log.Add("refresh with " + scope.Db + " and again " + scope.Db); } Log.AddRange(scope.Log); }} public static class Solution{ public static List<string> Run() { var cache = new PriceCache(new ScopeFactory()); cache.Refresh(); cache.Refresh(); return cache.Log; }}Common mistakes
- Fixing the exception by making
ShopDba singleton too. The error goes away and every request now shares one non-thread-safe context. - Calling
CreateScope()once in the singleton's constructor and keeping it. That is the same capture with an extra step. - Assuming a transient in the middle makes it safe. A transient held by a singleton lives as long as the singleton, and so does everything it holds.
What interviewers ask
- What is a captive dependency and why is it dangerous with a
DbContext? A longer-lived service holding a shorter-lived one; stale tracked entities, unbounded tracking, and concurrent-use exceptions. - How do you use a scoped service from a singleton or a background
service?
IServiceScopeFactory.CreateScope()per unit of work, orIDbContextFactory<T>; dispose what you create. - Why did this throw on your machine but not in production? Scope validation is on in Development by default.
Exercise · Pro
Validate scopes like the container
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade