Managing Service Lifetimes in .NET Core Dependency Injection
Learn how to choose between Transient, Scoped, and Singleton lifetimes in .NET Core to avoid memory leaks and captive dependency errors.
25 Nov 2025, 20:49 UTC

Incorrect service lifetime selection in .NET Core typically leads to two critical failures: ObjectDisposedException caused by captive dependencies, or memory leaks resulting from unintended object retention. The decision depends on whether a service must maintain state across multiple requests or remain isolated to a single operation.
This guide evaluates the three primary lifetimes—Transient, Scoped, and Singleton—to provide a framework for selecting the correct registration pattern based on memory and thread-safety constraints.
| Lifetime | Instance Frequency | Thread Safety Required? | Primary Use Case |
|---|---|---|---|
| Transient | Every time requested | No (typically stateless) | Lightweight utilities, mapping logic |
| Scoped | Once per client request | Per-request isolation | DbContext, User Session state |
| Singleton | Once per application life | Yes (concurrent access) | Caching, Configuration, Telemetry |
Transient: High-Frequency, Low-State
Transient services are instantiated every time they are requested from the service provider. This is the safest lifetime for stateless logic because there is no risk of state bleeding between different parts of the application. However, registering large objects as Transient can increase memory pressure and trigger frequent Garbage Collection (GC) cycles if they are instantiated inside tight loops.
Scoped: Request-Bound Consistency
Scoped services are created once per logical scope, which in ASP.NET Core corresponds to a single HTTP request. This ensures that every component participating in a specific request shares the same instance. This is the required pattern for Entity Framework DbContext to ensure that changes are tracked consistently across different repositories before being committed in a single transaction.
Singleton: Global Application State
Singletons are created on the first request and persist until the process terminates. Because a Singleton is accessed concurrently by every incoming request, any internal state must be managed using thread-safe primitives, such as ConcurrentDictionary or SemaphoreSlim. Failure to ensure thread safety in a Singleton often results in race conditions that are difficult to debug in production.
The Captive Dependency Problem
A captive dependency occurs when a service with a longer lifetime holds a reference to a service with a shorter lifetime. For example, if a Singleton service injects a Scoped service, that Scoped service is effectively promoted to a Singleton. It will not be disposed of at the end of the HTTP request, leading to memory leaks or errors when the captured service attempts to access a disposed resource (like a database connection).
Implementation and Validation
To prevent captive dependencies, enable scope validation in your development environment. This configuration forces the application to throw an exception during startup if a Singleton attempts to resolve a Scoped service.
// Run in Program.cs / Startup.cs
var builder = WebApplication.CreateBuilder(args);
// Enable validation to catch captive dependencies at startup
builder.Host.ValidateScopes = true;
builder.Host.ValidateOnStartup = true;
// Example Registrations
builder.Services.AddTransient<IMapperService, MapperService>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
var app = builder.Build();
To verify that a Scoped service is behaving correctly, inject the service twice into a single controller. If the lifetime is correctly set to Scoped, the object references will be identical for the duration of that request.
[ApiController]
[Route("api/lifetime-check")]
public class LifetimeController : ControllerBase
{
private readonly IScopedService _instanceOne;
private readonly IScopedService _instanceTwo;
public LifetimeController(IScopedService s1, IScopedService s2)
{
_instanceOne = s1;
_instanceTwo = s2;
}
[HttpGet]
public IActionResult Verify()
{
// ReferenceEquals returns true if both variables point to the same memory address
bool isSameInstance = ReferenceEquals(_instanceOne, _instanceTwo);
return Ok(new { IsScoped = isSameInstance });
}
}
Limitations: Scope validation only detects dependencies resolved via the constructor. It does not detect captive dependencies created via manual service provider resolution (the Service Locator pattern). To monitor the actual impact of Transient objects on memory, use dotnet-counters monitor -p [PID] to track the GC heap size.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.