Deep Dive into .NET Core DI Container: Lifecycles, Captive Dependency & Autofac

This article analyzes .NET Core's built-in DI container, explaining Transient, Scoped, and Singleton lifecycles with underlying mechanisms, demonstrates Captive Dependency pitfalls and three resolution strategies, compares built-in DI with Autofac's advanced features, and provides a selection decision matrix.

Feidie Network
Feidie Network
Feidie Network
Deep Dive into .NET Core DI Container: Lifecycles, Captive Dependency & Autofac

Dependency Injection Essence: From Active Creation to Passive Retrieval

Dependency Injection (DI) is an Inversion of Control (IoC) implementation that decouples classes from their dependencies' creation. Clients receive dependencies via constructor, property, or method parameters instead of using new. In ASP.NET Core, the DI container is the foundational infrastructure. When a service instance is needed, the container provides it based on pre-registered mappings, enabling loose coupling and testability — business code depends on abstractions, not concrete implementations.

From an architect's perspective, the DI container's value lies not in automatic object creation but in unified management of object lifecycles and dependency graphs . In large systems, dependency graphs become extremely complex, making manual management of creation order and lifecycles infeasible.

Three Service Lifecycles: From Principles to Practice

.NET DI container provides three predefined service lifecycle patterns that differ fundamentally in instance creation timing and recycling strategy .

2.1 Transient (Transient)

Behavior: Every service request from the container creates a brand new instance . Each consumer and each resolution gets an independent instance.

Underlying Mechanism: On each GetService call, the container directly invokes the registered factory delegate to create an instance without caching.

Applicable Scenarios:

Lightweight, stateless services (e.g., simple calculators, data formatting tools)

Non-thread-safe types (each consumer owns an independent instance, no sharing issues)

Short-lived objects requiring independent state

Typical Registration:

builder.Services.AddTransient<ILogger, Logger>();</code><code>builder.Services.AddTransient<IPerformanceTracker, PerformanceTracker>();

Note: Transient services are disposed at request end (if implementing IDisposable), but unlike Scoped they are not batch-processed; they are disposed individually when the container is released.

2.2 Scoped (Scoped)

Behavior: Within a scope , all resolution requests return the same instance . For web apps, each HTTP request is a scope.

Underlying Mechanism: The container maintains an instance cache ( ConcurrentDictionary) per scope. On first resolution within a scope, it creates and caches the instance; subsequent resolutions return the cached instance directly.

// Simplified DI framework Scoped implementation sketch</code><code>case Lifetime.Self:</code><code>  return GetOrCreate(_services, _disposables); // Look up in current container cache

Applicable Scenarios:

Database context (DbContext) — EF Core registers as Scoped by default

Services needing shared state within a single request

Unit of Work pattern implementations

Typical Registration:

builder.Services.AddScoped<AppDbContext>();</code><code>builder.Services.AddScoped<IOrderRepository, OrderRepository>();

Key Mechanism: In web apps, the framework automatically creates a scope at each HTTP request start and disposes the scope and all its managed Scoped services at request end.

2.3 Singleton (Singleton)

Behavior: Throughout the entire application lifecycle, only one instance exists . All resolution requests share the same instance.

Underlying Mechanism: The container stores the instance in the root container cache. All child containers and requests retrieve the same instance from the root container.

// Simplified DI framework Root/Singleton implementation sketch</code><code>case Lifetime.Root:</code><code>  return GetOrCreate(_root._services, _root._disposables); // Stored in root container

Applicable Scenarios:

Global configuration/options (e.g., IOptions<T>)

Cache services (e.g., IMemoryCache)

Stateless thread-safe services (e.g., loggers)

Factory classes

Typical Registration:

builder.Services.AddSingleton<ICacheService, RedisCacheService>();</code><code>builder.Services.AddSingleton<IMemoryCache, MemoryCache>();

Thread Safety Requirement: Singleton services are accessed by multiple threads simultaneously and must be thread-safe . If a singleton holds a non-thread-safe dependency (like DbContext), severe concurrency issues arise.

2.4 Three Lifecycles Comparison Summary

[Image: comparison table showing creation timing, cache strategy, disposal mechanism, and typical use cases for each lifecycle]

Lifecycle Misuse Consequences — Captive Dependency

Captive Dependency occurs when a longer-lived service holds a reference to a shorter-lived service . More precisely: when a shorter-lived dependency is injected into a longer-lived consumer, captive dependency occurs.

Example:

// ❌ Wrong: Singleton service injects Scoped service</code><code>public class LongTermService : BackgroundService // Actually Singleton</code><code>{</code><code>  private readonly IDbContext _context; // Registered as Scoped</code><code>  public LongTermService(IDbContext context) {</code><code>    _context = context;</code><code>  }</code><code>}</code><code>// Registration</code><code>builder.Services.AddScoped<IDbContext, SqlServerDbContext>();</code><code>builder.Services.AddHostedService<LongTermService>(); // Defaults to Singleton

3.1 Why Captive Dependency Is Dangerous

Problem 1: Lifecycle Confusion. Scoped services should be disposed at each request end, but because a Singleton holds a reference, they become "pseudo-singletons" — their lifecycle extends to application shutdown. This causes resource leaks (e.g., database connection leaks) and memory leaks .

Problem 2: Thread Safety Issues. Scoped services are typically non-thread-safe (like DbContext), designed for one instance per request. When shared by a Singleton, multiple threads may concurrently access the same instance, causing data corruption and hard-to-debug concurrency errors.

Problem 3: State Pollution. A Singleton holding a Scoped service means cross-request state sharing — data from one request may be read by another, causing data chaos.

3.2 .NET DI Container Validation Mechanism

.NET DI container enables scope validation by default in development environments ( ValidateScopes = true), detecting and blocking Captive Dependency — throwing an exception at ServiceProvider build time:

Error while validating the service descriptor 'ServiceType:IHostedService Lifetime:Singleton ImplementationType: LongTermService':</code><code>Cannot consume scoped service 'IDbContext' from singleton 'LongTermService'.

However, in production environments , this validation is disabled by default (for performance). Captive Dependency then won't throw exceptions and may "appear to work" but latent problems can surface anytime.

Architect's view: .NET DI validation is a development-time safety net , not a production guarantee. Real assurance comes from correct architectural understanding of lifecycle dependencies at design time . You can manually enable ValidateScopes = true in any environment, but recognize this is for development efficiency, not performance optimization.

3.3 Three Solutions for Captive Dependency

Solution 1: Unify Lifecycles. Change the consumer's lifecycle to be shorter than or equal to the dependency's. Register LongTermService as Scoped instead of Singleton.

Solution 2: Extend Dependency Lifecycle. Register IDbContext as Singleton ( not recommended — DbContext is inherently non-thread-safe).

Solution 3: Use Factory Pattern (Recommended). Inject IServiceScopeFactory into the Singleton, create an independent scope per operation to resolve Scoped services:

// ✅ Correct: Singleton obtains Scoped service via scope factory</code><code>public class LongTermService : BackgroundService {</code><code>  private readonly IServiceScopeFactory _scopeFactory;</code><code>  public LongTermService(IServiceScopeFactory scopeFactory) {</code><code>    _scopeFactory = scopeFactory;</code><code>  }</code><code>  protected override async Task ExecuteAsync(CancellationToken stoppingToken) {</code><code>    while (!stoppingToken.IsCancellationRequested) {</code><code>      using var scope = _scopeFactory.CreateScope();</code><code>      var context = scope.ServiceProvider.GetRequiredService<IDbContext>();</code><code>      // Use context...</code><code>      await Task.Delay(1000, stoppingToken);</code><code>    }</code><code>  }</code><code>}

Built-in DI Container Capability Boundaries

.NET's built-in DI container ( Microsoft.Extensions.DependencyInjection) provides basic but sufficient DI capabilities covering most common scenarios.

Built-in DI Supported Features:

Three lifecycles (Transient/Scoped/Singleton)

Constructor injection, property injection (limited support)

Multiple implementations of same interface via IEnumerable<T> From .NET 8: Keyed Services using [FromKeyedServices] attribute to distinguish implementations

Generic service registration and resolution

Factory delegate registration

Built-in DI Limitations:

No named registration (.NET 8 Keyed Services is the alternative)

No AOP/interceptors (for cross-cutting concerns like logging, performance monitoring)

No modular organization (cannot encapsulate related registrations into independent modules)

Limited property injection support

Weak metadata resolution (e.g., delayed resolution via Lazy<T>, Func<T>)

Third-Party DI Container — Autofac's Extended Capabilities & Selection

When built-in DI cannot meet complex needs, Autofac is the most widely used third-party container in the .NET ecosystem.

5.1 Autofac Core Features

Rich Registration Syntax: Autofac supports lambda-based declarative registration with strong expressiveness:

builder.RegisterType<Logger>().As<ILogger>().InstancePerLifetimeScope();</code><code>builder.RegisterType<OrderService>().As<IOrderService>().InstancePerRequest();

Advanced Lifecycle Management: InstancePerLifetimeScope (corresponds to Scoped) InstancePerRequest (corresponds to Scoped in ASP.NET Core) SingleInstance (corresponds to Singleton) InstancePerDependency (corresponds to Transient)

Supports nested scopes and async context isolation ( AsyncLocal)

Named/Keyed Registration: Autofac natively supports Named<T> and Keyed<T>, more mature than .NET 8 Keyed Services:

builder.RegisterType<WxPay>().Named<IPay>("wx");</code><code>builder.RegisterType<AliPay>().Named<IPay>("ali"); // Resolve by name</code><code>var pay = scope.ResolveNamed<IPay>("wx");

Modular Design: Encapsulate related registrations via Module class for management and reuse:

public class MyApplicationModule : Module {</code><code>  protected override void Load(ContainerBuilder builder) {</code><code>    builder.RegisterType<MyService>().As<IMyService>(); // Batch registration, conditional registration, etc.</code><code>  }</code><code>}

AOP/Interceptor Support: Implement cross-cutting concerns like logging, performance monitoring via interceptors.

Auto-Registration (Auto-Registration): Automatically register types matching rules via assembly scanning.

5.2 Performance Comparison

Autofac uses compile-time expression tree caching internally, avoiding reflection overhead, achieving zero-allocation object resolution paths , showing significant advantages in high-concurrency Web APIs or microservice gateways where throughput matters. .NET 6+ built-in DI uses Source Generators to generate compile-time resolution code, also achieving zero-reflection overhead.

Architect's view: Built-in DI and Autofac performance differences are negligible in ordinary web apps. Differences become significant only in extreme high concurrency (tens of thousands of requests/second) and complex dependency graphs (dozens of nested dependency layers).

5.3 Integration Approach

Deeply integrate Autofac with ASP.NET Core HostBuilder for two-container coexistence — built-in DI registers first, Autofac supplements:

builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory())</code><code>  .ConfigureContainer<ContainerBuilder>(containerBuilder =></code><code>  {</code><code>    containerBuilder.RegisterModule<MyApplicationModule>();</code><code>    // Autofac-specific capabilities: named registration, AOP interceptors, etc.</code><code>  }</code><code>);

5.4 Autofac vs Built-in DI — Selection Decision Matrix

[Image: decision matrix comparing features]

Selection Guidance:

Prefer built-in DI: Most regular web apps, microservices, Worker Services are fully covered. .NET 8 Keyed Services eliminated 80% of past reasons to switch to Autofac.

Switch to Autofac when: Need AOP interceptors for cross-cutting concerns, more complex lifecycle management (e.g., nested scopes), extensive named registration for multiple implementations, modular registration organization.

Industry Trend: As built-in DI matures, the community is gradually migrating back from Autofac. Some teams explicitly decided to "simplify codebase, reduce external dependencies" by removing Autofac from templates and using .NET built-in DI instead.

Interview Follow-up Points

1. "Can three lifecycles be mixed? Any restrictions?"

Yes, but must follow the Dependency Direction Principle : longer-lived services cannot depend on shorter-lived ones (Singleton cannot depend on Scoped or Transient, Scoped cannot depend on Transient). Reverse is safe — Transient can depend on Singleton.

2. "Why must DbContext be registered as Scoped?"

DbContext is designed as non-thread-safe and expects an independent instance per request to isolate data changes. Scoped ensures all components in the same request share one DbContext instance, while different requests are isolated. Singleton causes concurrent read/write issues; Transient loses cross-component change tracking sharing.

3. "Difference between .NET 8 Keyed Services and Autofac Named registration?"

Keyed Services is a .NET 8 built-in feature using [FromKeyedServices] attribute to distinguish implementations. It covers Autofac Named registration's core scenario — on-demand resolution of multiple implementations — but remains less mature in AOP support, modular organization, metadata handling .

4. "How to implement a DI container supporting property injection?"

Built-in DI supports limited property injection — only for specific framework components (Controller, Razor Pages), not regular services. Autofac natively supports property injection via PropertiesAutowired() or OnActivated lifecycle hooks to inject property dependencies.

5. "Should scope validation (ValidateScopes) be enabled in production?"

Enabled by default in development, disabled by default in production (performance). Enabling in production adds validation overhead but catches lifecycle misuse. Recommended: keep enabled in development , rely on architectural design and testing for production correctness.

Conclusion

The DI container is the skeleton of ASP.NET Core architecture. Key takeaways:

Three lifecycles essence: Transient (new each time), Scoped (shared per scope), Singleton (global shared). Understanding their creation timing, caching strategy, and disposal mechanism is the foundation of correct usage.

Captive Dependency: Longer-lived service captures shorter-lived dependency, causing resource leaks, thread safety issues, state pollution . Development environment detects and blocks by default; production relies on architectural design.

Three Captive Dependency resolution strategies: Unify lifecycles, extend dependency lifecycle, use IServiceScopeFactory to create independent scopes per operation.

Built-in DI vs Autofac: Built-in DI covers 80% of regular scenarios; .NET 8 Keyed Services further narrows the gap. When AOP interceptors, complex named registration, modular organization are needed, Autofac is the mature enhancement.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Dependency Injection.NET CoreService LifecycleDI ContainerAutofacCaptive DependencyIServiceScopeFactoryKeyed Services
Feidie Network
Written by

Feidie Network

Make every read truly rewarding: a knowledge framework and interview guide for .NET architects.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.