Choosing the Right Service Lifetime in .NET Dependency Injection
A practical guide to Transient, Scoped, and Singleton lifetimes in .NET's built-in DI container, with a runnable example showing how to observe instance identity across requests and why captive dependencies slip through in Production.
05 Aug 2025, 09:58 UTC

The Problem: Lifetime Mismatches Cause Silent Bugs
You register a database context as Scoped, a cache as Singleton, and a logger as Transient. The app builds, runs, and passes CI. Two weeks later, users report stale data in background jobs and memory growth in long-running services. The culprit isn't a missing index or a leaky connection—it's a Singleton service quietly holding a reference to a Scoped DbContext through constructor injection.
This post walks through how .NET's built-in container handles the three core lifetimes, where the boundaries actually live, and how to verify behavior before it reaches production.
Transient: New Instance Every Request
Transient services are created each time they're resolved from the container. If you inject ITransientService into three controllers within the same HTTP request, you get three distinct instances. This is the safest default for stateless, lightweight components—think email formatters, validation helpers, or HTTP client wrappers that don't hold connections.
services.AddTransient<IEmailFormatter, EmailFormatter>();
Because no state is shared, Transient services avoid threading concerns entirely. The trade-off is allocation pressure: a high-frequency background task resolving a Transient service thousands of times per second will churn Gen 0. Profile before optimizing.
Scoped: Once Per Logical Operation
Scoped services live for the duration of a scope. In ASP.NET Core, the framework creates a scope per HTTP request. All middleware, controllers, and services participating in that request share the same instance. This is the correct lifetime for DbContext, Unit of Work implementations, and any service that tracks per-request state.
services.AddScoped<AppDbContext>();
Outside the request pipeline—background services, hosted services, console apps—no ambient scope exists. You must create one explicitly via IServiceScopeFactory:
public class OrderProcessor : BackgroundService {
private readonly IServiceScopeFactory _scopeFactory;
public OrderProcessor(IServiceScopeFactory scopeFactory) {
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken ct) {
while (!ct.IsCancellationRequested) {
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// process orders
await Task.Delay(5000, ct);
}
}
}
Each loop iteration gets a fresh DbContext, preventing entity tracking leaks across batches.
Singleton: Process-Wide Shared Instance
Singleton services are instantiated once—the first time they're resolved—and reused for the application's lifetime. Configuration objects, in-memory caches, and feature-flag providers are typical candidates. Because the same instance serves all threads, Singletons must be thread-safe.
services.AddSingleton<IFeatureFlags, FeatureFlags>();
If a Singleton captures a Scoped dependency in its constructor, the Scoped instance becomes effectively Singleton—a captive dependency. The container won't stop you in Production, but in Development it throws InvalidOperationException: "Cannot resolve scoped service 'AppDbContext' from root provider."
Worked Example: Observing Lifetimes at Runtime
Create three empty marker interfaces and implementations that log their hash codes:
public interface ITransientTracker { int Id { get; } }
public interface IScopedTracker { int Id { get; } }
public interface ISingletonTracker { int Id { get; } }
public class TransientTracker : ITransientTracker {
public int Id { get; } = GetHashCode();
}
public class ScopedTracker : IScopedTracker {
public int Id { get; } = GetHashCode();
}
public class SingletonTracker : ISingletonTracker {
public int Id { get; } = GetHashCode();
}
Register them in Program.cs:
builder.Services.AddTransient<ITransientTracker, TransientTracker>();
builder.Services.AddScoped<IScopedTracker, ScopedTracker>();
builder.Services.AddSingleton<ISingletonTracker, SingletonTracker>();
Inject all three into a minimal API endpoint and hit it twice:
app.MapGet("/lifetimes", (
ITransientTracker t,
IScopedTracker s,
ISingletonTracker sg) =>
{
return new {
Transient = t.Id,
Scoped = s.Id,
Singleton = sg.Id
};
});
Expected output across two requests:
| Request | Transient | Scoped | Singleton |
|---|---|---|---|
| 1 | 12345 | 11111 | 99999 |
| 2 | 67890 | 22222 | 99999 |
Transient changes every call. Scoped changes per request. Singleton stays identical. Run this in Development to confirm the container's validation catches captive dependencies—try injecting IScopedTracker into SingletonTracker's constructor and watch the startup exception.
Trade-off: Validation Only Runs in Development
The captive-dependency check is disabled in Production for performance. A misconfigured Singleton capturing a Scoped service will silently persist that Scoped instance for the process lifetime, leading to connection leaks, stale entity tracking, or thread-safety violations. The only reliable guard is:
- Run the full test suite in Development environment locally and in CI.
- Add an integration test that resolves the full object graph from a fresh
IServiceProviderand asserts no exceptions. - Consider third-party analyzers that flag lifetime mismatches at compile time.
Actionable Closing: Audit Your Registrations
Open your Program.cs or composition root. For each registration, ask:
- Does this service hold state? If no → Transient.
- Does this state belong to a single logical operation (request, message, job batch)? If yes → Scoped.
- Is this state truly global and thread-safe? If yes → Singleton.
Then search for AddSingleton constructors accepting DbContext, IHttpContextAccessor, or any custom Scoped interface. Move those dependencies to a factory (Func<IServiceScope, T>) or resolve them inside a manually created scope. Run the app in Development, hit every endpoint, and verify no scope validation exceptions appear. That five-minute audit prevents the class of bugs that only surface at 3 AM under load.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.