Bob opens his shopping cart and finds a laptop he never added. Ada added it, on a different computer, a few minutes earlier.
Nothing was hacked. One line of dependency injection registration did it. Every snippet below compiles on .NET 10, and every output is copied from actually running it.
1. The three lifetimes, with proof
services.AddTransient<Clock>(); // new one every time
services.AddScoped<Cart>(); // one per request
services.AddSingleton<Catalog>(); // one for the whole app
Each class gets a short random ID when it's constructed. Resolve each service twice per request, for two requests:
request 1:
Clock 33e1 bbc8
Cart 8faa 8faa
Catalog 8d8d
request 2:
Clock c87d ab23
Cart 1139 1139
Catalog 8d8d
-
Transient: a new
Clockon every resolve, even within the same request. -
Scoped: the same
Cartwithin a request, a new one for the next request. -
Singleton: one
Catalogfor the lifetime of the app.
2. The captive dependency
Now a checkout service that uses the cart. It's stateless-looking, so someone registers it as a singleton:
services.AddScoped<Cart>();
services.AddSingleton<CheckoutService>();
public sealed class CheckoutService(Cart cart)
{
public void Add(string item) => cart.Items.Add(item);
public string Show() => string.Join(", ", cart.Items);
}
Ada adds a laptop in request 1, Bob adds headphones in request 2:
request 1 (Ada) cart: laptop
request 2 (Bob) cart: laptop, headphones
The singleton is created once, so it receives the Cart from the first request and keeps it forever. Every user after Ada shares her cart. The scoped service has been "captured" by a longer-lived one.
The fix: a service can't live longer than the things it depends on. Make the consumer scoped too:
services.AddScoped<Cart>();
services.AddScoped<CheckoutService>();
request 1 (Ada) cart: laptop
request 2 (Bob) cart: headphones
(If a singleton genuinely needs scoped work, inject IServiceScopeFactory and create a scope per operation instead of holding the scoped service.)
3. Let the container catch it for you
Two options turn this bug into a startup error:
var provider = services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = true,
ValidateOnBuild = true,
});
With the bad registrations, the app refuses to start:
System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: CheckoutService Lifetime: Singleton ImplementationType: CheckoutService': Cannot consume scoped service 'Cart' from singleton 'CheckoutService'.)
With the fixed registrations, it builds fine.
Worth knowing: ASP.NET Core and the generic host already turn both checks on, but only in the Development environment. If you build a ServiceProvider yourself (console apps, tests, workers outside the host), you have to enable them.
They aren't magic either. Validation only catches what it can see from the registrations, so it doesn't replace understanding lifetimes.
4. The quiet leak: disposable transients
This one never throws. A nightly job resolves an IDisposable transient from the root provider:
// services.AddTransient<ReportExporter>(); (IDisposable)
for (var i = 0; i < 1000; i++)
{
var exporter = provider.GetRequiredService<ReportExporter>();
exporter.Export();
}
after the loop: created 1000, disposed 0
after app shutdown: disposed 1000
The container tracks every disposable it creates so it can dispose them later. Resolved from the root, "later" means when the app shuts down. Until then all 1,000 exporters, and whatever they hold, stay in memory.
The fix: one scope per unit of work.
for (var i = 0; i < 1000; i++)
{
using var scope = provider.CreateScope();
var exporter = scope.ServiceProvider
.GetRequiredService<ReportExporter>();
exporter.Export();
}
after the loop: created 1000, disposed 1000
Recap: 3 rules
- A service should never outlive its dependencies. Singleton → scoped is the classic captive dependency.
- Turn on
ValidateScopesandValidateOnBuildanywhere the host doesn't do it for you. - Resolve disposable transients inside a scope, never from the root provider in a loop.
What's the strangest bug a DI lifetime has caused you? I'd like to hear it in the comments.
I make short, tested videos about C# bugs that compile fine and still break things. More on the Naze Code YouTube channel.
Sources: Microsoft Learn, DI guidelines and HostBuilder validation in Development. Tested on .NET SDK 10.0.302 with Microsoft.Extensions.DependencyInjection 10.0.12.
Top comments (0)