Singleton, Scoped or Transient? Get It Wrong and Your DbContext Lives Forever
Choosing a lifetime means choosing when the container creates an instance and when it disposes it. Transient creates a new one on every resolve, Scoped gives one instance per HTTP request, Singleton one instance for the whole process. The single most important rule: a long-lived service must not take a short-lived one directly. Inject a Scoped repository into a Singleton and you have just kept that DbContext alive until the app shuts down. The fix is to inject IServiceScopeFactory and open a scope yourself when you need one.
The registration line that causes this looks so harmless that nobody pauses on it in review:
builder.Services.AddDbContext<CrmDbContext>(o => o.UseSqlServer(conn)); // Scoped
builder.Services.AddScoped<ICustomerRepository, EfCustomerRepository>();
builder.Services.AddSingleton<CustomerCacheRefresher>(); // ← the explosive
The app passes CI and reaches staging. Once it is under real load, the logs start showing A second operation was started on this context instance before a previous operation completed, or Cannot access a disposed context instance — or, worse, no exception at all and merely stale data that never changes. This post explains the mechanism behind it, with the code for both the broken and the fixed version.
