DEV Community

Naze Code
Naze Code

Posted on AI-assisted

Transient vs Scoped vs Singleton: the DI bug that put Ada's laptop in Bob's cart

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • Transient: a new Clock on every resolve, even within the same request.
  • Scoped: the same Cart within a request, a new one for the next request.
  • Singleton: one Catalog for 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);
}
Enter fullscreen mode Exit fullscreen mode

Ada adds a laptop in request 1, Bob adds headphones in request 2:

request 1 (Ada) cart: laptop
request 2 (Bob) cart: laptop, headphones
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode
request 1 (Ada) cart: laptop
request 2 (Bob) cart: headphones
Enter fullscreen mode Exit fullscreen mode

(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,
});
Enter fullscreen mode Exit fullscreen mode

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'.)
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode
after the loop: created 1000, disposed 0
after app shutdown: disposed 1000
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode
after the loop: created 1000, disposed 1000
Enter fullscreen mode Exit fullscreen mode

Recap: 3 rules

  1. A service should never outlive its dependencies. Singleton → scoped is the classic captive dependency.
  2. Turn on ValidateScopes and ValidateOnBuild anywhere the host doesn't do it for you.
  3. 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)