Loading content...
Your application runs flawlessly for weeks. Then, gradually, response times increase. Memory usage climbs. Database connection pools exhaust. Eventually, the server crashes with an out-of-memory error or a 'no free connections' exception. The postmortem reveals something strange: a singleton service was holding references to database contexts that should have been disposed after each request.
This is a captive dependencyβone of the most insidious anti-patterns in dependency injection. It occurs when a component with a longer lifetime captures a dependency with a shorter lifetime, inadvertently extending that dependency's lifespan beyond its intended scope.
Unlike constructor over-injection (visible immediately) or circular dependencies (often fail at startup), captive dependencies can lurk for months. They manifest as gradual performance degradation, memory leaks, stale data bugs, and connection pool exhaustionβsymptoms that are notoriously difficult to diagnose.
By the end of this page, you will understand what captive dependencies are, how they arise from lifetime mismatches, recognize the symptoms of captive dependency problems, apply strategies to prevent and resolve captive dependencies, and configure IoC containers to detect these issues automatically.
Before understanding captive dependencies, we must understand lifetime scopesβthe duration for which a dependency instance exists. Most IoC containers support three primary lifetimes:
| Lifetime | Duration | Instance Behavior | Typical Use Cases |
|---|---|---|---|
| Transient | Shortest | New instance every time requested | Stateless services, lightweight processors, factories |
| Scoped | Medium | One instance per scope (e.g., HTTP request) | Database contexts, unit of work, request-specific state |
| Singleton | Longest | Single instance for application lifetime | Configuration, caches, connection pools, stateless coordinating services |
The Lifetime Hierarchy:
Think of lifetimes as a hierarchy from shortest to longest:
Transient (shortest) β Scoped (medium) β Singleton (longest)
The fundamental rule is: A service can only safely depend on services with equal or longer lifetimes. A singleton can depend on another singleton. A scoped service can depend on scoped or singleton services. But when a singleton depends on a scoped or transient service, problems ariseβthis is the captive dependency anti-pattern.
123456789101112131415161718192021222324252627
// Conceptual model: Lifetimes as containers // SINGLETON: Lives for entire application// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ// β Singleton Scope (Application Lifetime) β// β β// β βββββββββββββββββββββββ βββββββββββββββββββββββ β// β β Scoped (Request 1) β β Scoped (Request 2) β ... β// β β β β β β// β β βββββββββββββββββ β β βββββββββββββββββ β β// β β β Transient 1a β β β β Transient 2a β β β// β β βββββββββββββββββ β β βββββββββββββββββ β β// β β βββββββββββββββββ β β βββββββββββββββββ β β// β β β Transient 1b β β β β Transient 2b β β β// β β βββββββββββββββββ β β βββββββββββββββββ β β// β βββββββββββββββββββββββ βββββββββββββββββββββββ β// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // β
ALLOWED: Inner can depend on outer (or same level)// - Transient depends on Scoped: β// - Transient depends on Singleton: β// - Scoped depends on Singleton: β // β FORBIDDEN: Outer depending on inner (CAPTIVE DEPENDENCY)// - Singleton depends on Scoped: β (Scoped dies, Singleton keeps reference)// - Singleton depends on Transient: β (Same problem)// - Scoped depends on Transient: β οΈ (Often okay, but can cause issues)Dependencies should flow from short-lived to long-lived, never the reverse. A request-scoped service can depend on a singleton cache, but a singleton cache must never depend on a request-scoped database context.
A captive dependency occurs when a service with a longer lifetime holds a reference to a service with a shorter lifetime. The shorter-lived service becomes "captive"βimprisoned within the longer-lived service beyond its natural lifespan.
Let's examine a concrete example:
1234567891011121314151617181920212223242526272829303132333435363738
// π¨ ANTI-PATTERN: Singleton captures Scoped dependency // This is registered as a SINGLETON - one instance for the entire appclass ProductCatalogCache { private cache = new Map<string, Product>(); constructor( // β PROBLEM: DbContext is SCOPED (per-request) // But this class is SINGLETON (per-application) private readonly dbContext: DbContext ) {} async getProduct(productId: string): Promise<Product> { if (this.cache.has(productId)) { return this.cache.get(productId)!; } // This dbContext was created during the FIRST request // and is now being used for ALL subsequent requests! const product = await this.dbContext.products.findById(productId); this.cache.set(productId, product); return product; }} // Container configuration (typical setup)container.register(DbContext, { scope: Scope.Request }); // Scopedcontainer.register(ProductCatalogCache, { scope: Scope.Singleton }); // Singleton // What happens at runtime:// 1. Request 1 arrives// 2. ProductCatalogCache is created (first time, since singleton)// 3. DbContext for Request 1 is injected// 4. Request 1 completes, DbContext should be disposed// 5. But ProductCatalogCache still holds the reference!// 6. Request 2 arrives// 7. ProductCatalogCache reuses the captured (now stale/disposed) DbContext// 8. π₯ Database operations fail or return stale dataWhy This Is Catastrophic:
The DbContext was designed to be short-lived for several important reasons:
When the singleton captures it, all these assumptions are violated. The context lives forever, connections are never released, change tracking becomes corrupted, and memory leaks accumulate.
Captive dependencies don't fail immediately. The first request works perfectly. Often the second and third work too, depending on how the captured service handles reuse. The failures accumulate slowlyβconnection pool exhaustion after hours, memory pressure after days, mysterious data corruption after weeks.
Captive dependencies manifest through various symptoms, each challenging to diagnose without understanding the underlying cause:
| Symptom | Likely Cause | Verification Approach |
|---|---|---|
| Memory never stabilizes after GC | Singleton holding transient/scoped references | Heap dump analysis; look for unexpected object retention |
| Database connection errors after uptime | Captive DbContext holding connections | Monitor connection pool; check singleton registrations |
| Same data across different users | Scoped user context captured by singleton | Add request IDs to logs; trace service instantiation |
| ObjectDisposedException randomly | Scoped service disposed while singleton uses it | Enable container diagnostics; trace disposal lifecycle |
| Race conditions in 'safe' code | Request-scoped service used by multiple threads | Review singleton dependencies for scoped services |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// Diagnostic wrapper to detect captive dependency at runtime class LifetimeTrackingProxy<T extends object> { private readonly createdAt = new Date(); private readonly createdInScope: string; private usageCount = 0; private lastUsedAt?: Date; private lastUsedInScope?: string; constructor( private readonly target: T, private readonly serviceName: string, private readonly expectedLifetime: 'transient' | 'scoped' | 'singleton', scopeId: string ) { this.createdInScope = scopeId; } createProxy(currentScopeId: string): T { return new Proxy(this.target, { get: (obj, prop) => { this.usageCount++; this.lastUsedAt = new Date(); this.lastUsedInScope = currentScopeId; // DETECTION: If scoped service is used in different scope than created if (this.expectedLifetime === 'scoped' && this.createdInScope !== currentScopeId) { console.error( `β οΈ CAPTIVE DEPENDENCY DETECTED: ${this.serviceName}\n` + ` Created in scope: ${this.createdInScope}\n` + ` Used in scope: ${currentScopeId}\n` + ` Service was created ${this.getAge()} ago\n` + ` This indicates a singleton is holding a scoped dependency!` ); } return obj[prop as keyof T]; } }); } private getAge(): string { const ms = Date.now() - this.createdAt.getTime(); if (ms < 1000) return `${ms}ms`; if (ms < 60000) return `${(ms/1000).toFixed(1)}s`; return `${(ms/60000).toFixed(1)} minutes`; }}Most IoC containers have built-in captive dependency detection that can be enabled in development. ASP.NET Core's 'ValidateScopes' option, Autofac's 'LifetimeScopeBeginning' event, and NestJS's strict mode all catch these issues at startup or first occurrence.
Captive dependencies arise in predictable scenarios. Recognizing these patterns helps you avoid them before they're written:
The most common scenarioβa global cache that needs to refresh from the database:
12345678910111213141516171819202122232425262728293031323334353637383940
// π¨ ANTI-PATTERN: Common captive dependency scenario // Singleton cache that needs database accessclass GlobalProductCache { private cache = new Map<string, Product>(); constructor( private readonly productRepository: IProductRepository // SCOPED! ) {} async getProduct(id: string): Promise<Product> { if (!this.cache.has(id)) { // Uses scoped repository - CAPTIVE! const product = await this.productRepository.findById(id); this.cache.set(id, product); } return this.cache.get(id)!; }} // β
SOLUTION: Inject factory instead of instanceclass GlobalProductCache { private cache = new Map<string, Product>(); constructor( // Factory creates new scoped instance each call private readonly repositoryFactory: () => IProductRepository ) {} async getProduct(id: string): Promise<Product> { if (!this.cache.has(id)) { // Create fresh scoped repository for this operation const repository = this.repositoryFactory(); const product = await repository.findById(id); this.cache.set(id, product); // Repository goes out of scope and can be disposed } return this.cache.get(id)!; }}Background jobs (queues, scheduled tasks) often inadvertently capture request-scoped services:
12345678910111213141516171819202122232425262728293031323334353637
// π¨ ANTI-PATTERN: Background job captures request-scoped service class OrderProcessingJob { constructor( private readonly orderService: OrderService, // Request-scoped! private readonly emailService: EmailService // Request-scoped! ) {} // This runs outside any HTTP request scope async processOrder(orderId: string): Promise<void> { // Using services from the scope in which the job was QUEUED // not the scope in which it RUNS const order = await this.orderService.getOrder(orderId); await this.emailService.sendConfirmation(order); }} // β
SOLUTION: Create scope per job executionclass OrderProcessingJob { constructor( private readonly scopeFactory: IServiceScopeFactory ) {} async processOrder(orderId: string): Promise<void> { // Create new scope for this job execution using scope = this.scopeFactory.createScope(); // Resolve fresh services from the new scope const orderService = scope.resolve(OrderService); const emailService = scope.resolve(EmailService); const order = await orderService.getOrder(orderId); await emailService.sendConfirmation(order); // Scope disposed here, all scoped services cleaned up }}Event handlers registered during configuration often capture the services available at that moment:
1234567891011121314151617181920212223
// π¨ ANTI-PATTERN: Event handler captures configuration-time services class ApplicationStartup { configure(eventBus: EventBus, userService: UserService) { // userService is from the startup scope - NOT request scoped! eventBus.subscribe('UserCreated', async (event) => { // This closure captures userService forever await userService.sendWelcomeEmail(event.userId); }); }} // β
SOLUTION: Subscribe with service resolver, not instanceclass ApplicationStartup { configure(eventBus: EventBus, scopeFactory: IServiceScopeFactory) { eventBus.subscribe('UserCreated', async (event) => { // Create scope and resolve at event-handling time using scope = scopeFactory.createScope(); const userService = scope.resolve(UserService); await userService.sendWelcomeEmail(event.userId); }); }}Any closure (lambda, callback, event handler) that references an injected service captures that service instance. If the closure lives longer than the service's intended scope, you have a captive dependency. Be especially vigilant with event subscriptions, timer callbacks, and background task continuations.
When you've identified a captive dependency, several strategies can resolve it. Choose based on your specific situation:
The most common solutionβinject a factory that creates instances on demand:
123456789101112131415161718192021222324252627282930313233
// Instead of: constructor(private service: ScopedService)// Use: constructor(private serviceFactory: () => ScopedService) class SingletonOrchestrator { constructor( // Factory creates properly-scoped instance each time private readonly dbContextFactory: () => DbContext, private readonly userServiceFactory: () => UserService ) {} async performOperation(userId: string): Promise<Result> { // Each call gets fresh, properly-scoped instances const dbContext = this.dbContextFactory(); const userService = this.userServiceFactory(); try { const user = await userService.getUser(userId); // ... operations await dbContext.saveChanges(); return { success: true }; } finally { // Factory-created instances should be disposed if needed await dbContext.dispose(); } }} // Container configuration (Autofac-style)builder.RegisterType<DbContext>().AsSelf().InstancePerLifetimeScope();builder.Register(c => { var scope = c.Resolve<ILifetimeScope>(); return () => scope.Resolve<DbContext>();}).As<Func<DbContext>>().SingleInstance();For operations that need multiple scoped services together, create an entire scope:
1234567891011121314151617181920212223242526272829303132
// Creating a full scope ensures all services share the same lifecycle interface IServiceScopeFactory { createScope(): IServiceScope;} interface IServiceScope extends Disposable { resolve<T>(serviceType: new (...args: any[]) => T): T;} class SingletonBackgroundProcessor { constructor( private readonly scopeFactory: IServiceScopeFactory ) {} async processInBackground(): Promise<void> { // Create a scope that mimics a request scope using scope = this.scopeFactory.createScope(); // All services resolved from this scope share the same DbContext const orderService = scope.resolve(OrderService); const paymentService = scope.resolve(PaymentService); const notificationService = scope.resolve(NotificationService); // These services can interact normally, sharing their scoped dependencies const order = await orderService.getPendingOrder(); await paymentService.processPayment(order); await notificationService.notify(order.customerId); // Scope disposal cleans up all scoped services together }}Sometimes the solution is to reconsider whether the capturing service truly needs singleton lifetime:
12345678910111213141516171819
// Before: Singleton with captive scoped dependency// Ask: Does this REALLY need to be a singleton? // Original (problematic):container.register(ProductCache, Lifetime.Singleton);container.register(DbContext, Lifetime.Scoped);// ProductCache -> DbContext (CAPTIVE!) // Option A: Make the cache scoped too// (If state sharing isn't required across requests)container.register(ProductCache, Lifetime.Scoped);container.register(DbContext, Lifetime.Scoped);// Both scoped: No captive dependency! // Option B: Extract the stateful part to a separate singletoncontainer.register(ProductCacheStorage, Lifetime.Singleton); // Just datacontainer.register(ProductCacheLoader, Lifetime.Scoped); // Has DbContextcontainer.register(DbContext, Lifetime.Scoped);// Loader is scoped, Storage is singleton, no captive dependencyModern IoC containers provide built-in mechanisms to detect and prevent captive dependencies. Enable these features to catch problems before they reach production:
12345678910111213141516171819202122232425262728293031
// ASP.NET Core: Enable scope validation in development public class Program{ public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); // Enable scope validation in development if (builder.Environment.IsDevelopment()) { builder.Host.UseDefaultServiceProvider(options => { options.ValidateScopes = true; // Detect captive dependencies options.ValidateOnBuild = true; // Validate at startup, not first use }); } // Register services... builder.Services.AddScoped<IDbContext, DbContext>(); builder.Services.AddSingleton<ICacheService, CacheService>(); var app = builder.Build(); // If CacheService incorrectly depends on IDbContext, // ValidateScopes will throw at first HTTP request }} // Error message when captive dependency detected:// System.InvalidOperationException: Cannot consume scoped service // 'IDbContext' from singleton 'ICacheService'.123456789101112131415161718192021222324252627282930
// NestJS: Enable strict mode for scope validation import { NestFactory } from '@nestjs/core';import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'debug'], // Strict mode catches scope violations // Available in recent NestJS versions }); await app.listen(3000);} // Alternative: Manual scope checking in module@Module({ providers: [ { provide: 'SCOPE_VALIDATOR', useFactory: (scopedService: ScopedService) => { // This will fail if resolved in singleton context console.log('Scope validation passed'); }, inject: [ScopedService], scope: Scope.REQUEST, // Explicitly request-scoped }, ],})export class ValidationModule {}123456789101112131415161718192021222324
// Spring Framework: Proxy-based scope detection @Configurationpublic class ScopeValidationConfig { @Bean @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS) public RequestContext requestContext() { // Using proxy mode allows Spring to inject a proxy to singletons // The proxy delegates to the actual request-scoped instance return new RequestContext(); } @Bean @Scope("singleton") public SingletonService singletonService(RequestContext requestContext) { // This works because requestContext is a PROXY // Each call through the proxy resolves to current request's instance return new SingletonService(requestContext); }} // Note: This works but adds proxy overhead.// Prefer explicit scope factories for performance-critical paths.Run your test suite with scope validation enabled. This catches captive dependencies introduced by new code before it merges. The slight performance overhead during testing is insignificant compared to the production debugging time saved.
Beyond reactive fixes, certain design patterns structurally prevent captive dependencies from forming:
IRequestScopedUserContext vs ISingletonCache. The naming prevents accidental misuse.HttpContext.Current). These implicitly assume request scope and fail in singletons.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// Example: Clean separation by lifetime // === SINGLETON LAYER (Application-wide, stateless or self-contained) ===interface ISingletonCache { get<T>(key: string): T | undefined; set<T>(key: string, value: T, ttlMs: number): void;} interface ISingletonConfiguration { readonly databaseConnectionString: string; readonly cacheExpirationMs: number;} // Singleton implementations depend only on other singletonsclass ConfigurationService implements ISingletonConfiguration { constructor(/* only singletons or primitives */) {}} // === SCOPED LAYER (Per-request, contains request state) ===interface IScopedDbContext { readonly requestId: string; findById<T>(id: string): Promise<T>; save<T>(entity: T): Promise<void>;} interface IScopedCurrentUser { readonly userId: string; readonly permissions: string[];} // Scoped implementations can depend on scoped or singletonclass DbContext implements IScopedDbContext { constructor( private config: ISingletonConfiguration, // Singleton: OK private requestContext: RequestContext // Scoped: OK ) {}} // === TRANSIENT LAYER (Per-use, typically stateless) ===interface ITransientValidator<T> { validate(entity: T): ValidationResult;} // Transient can depend on any lifetimeclass OrderValidator implements ITransientValidator<Order> { constructor( private config: ISingletonConfiguration, // Singleton: OK private dbContext: IScopedDbContext, // Scoped: OK ) {}} // The naming convention makes violations obvious:// class MySingleton {// constructor(scopedUser: IScopedCurrentUser) {} // β Name reveals problem!// }Let's consolidate the key insights about captive dependencies:
Func<T> or IServiceScopeFactory instead of direct instances.What's Next:
We've now covered the three major DI anti-patterns: constructor over-injection, circular dependencies, and captive dependencies. Our final page consolidates everything into a comprehensive DI Best Practices Checklistβa practical reference you can apply to every project to ensure clean, maintainable dependency injection.
You now understand captive dependenciesβthe insidious anti-pattern where long-lived services capture short-lived dependencies. You can recognize symptoms, apply resolution strategies, enable container-level detection, and design systems that structurally prevent this problem. Next, we'll consolidate all DI knowledge into an actionable best practices checklist.