Every service registration requires a lifetime — how long the container keeps a service instance alive before creating a new one. Choosing the wrong lifetime is one of the most common and impactful DI bugs: a Scoped database context captured by a Singleton leaks state between requests; a Transient expensive service recreated on every injection wastes resources. The three lifetimes map to three distinct real-world requirements, and each service in your application should be registered with deliberate intent.
The Three Lifetimes
// ── SCOPED — one instance per HTTP request ────────────────────────────────
// Created when a request arrives; disposed when the request completes.
// The same instance is shared across all injections within ONE request.
// Use for: DbContext, repositories, application services that hold per-request state.
builder.Services.AddScoped<AppDbContext>();
builder.Services.AddScoped<IPostRepository, EfPostRepository>();
// Within one request: PostService and PostsController get the SAME EfPostRepository instance
// Across requests: each gets a fresh one
// ── SINGLETON — one instance for the application lifetime ─────────────────
// Created on first use; never disposed until the application shuts down.
// The same instance is shared across ALL requests and ALL threads.
// Use for: stateless shared services, in-memory caches, HttpClient factories.
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
// HttpClient configuration — always singleton via IHttpClientFactory
builder.Services.AddHttpClient<IGitHubClient, GitHubClient>();
// ── TRANSIENT — new instance every injection ──────────────────────────────
// Created fresh every time it is requested from the container.
// No sharing — each injection site gets its own instance.
// Use for: lightweight stateless utilities with no shared state.
builder.Services.AddTransient<ISlugGenerator, SlugGenerator>();
builder.Services.AddTransient<IPasswordHasher, BCryptPasswordHasher>();
AppDbContext is the canonical Scoped service. EF Core’s change tracker maintains state per-request (which entities are loaded, what has changed) — this state must be isolated per request. If DbContext were a Singleton, request A’s pending changes could be mixed with request B’s data. If it were Transient, two injections within the same request would get different change trackers, causing the unit-of-work pattern to break. Scoped is precisely the right lifetime for DbContext.ValidateScopes = true and ValidateOnBuild = true in development. In ASP.NET Core 6+, scope validation is enabled automatically in Development. For production environments where you want the same protection, add to the host builder: builder.Host.UseDefaultServiceProvider(opts => { opts.ValidateScopes = true; opts.ValidateOnBuild = true; }). This surfaces captive dependency bugs at startup rather than in production under load.Lifetime Summary Table
| Lifetime | Instance Created | Disposed | Thread Safety | Use For |
|---|---|---|---|---|
| Singleton | First request | App shutdown | Must be thread-safe | Caches, HttpClient, stateless shared services |
| Scoped | Each HTTP request | Request end | Single request thread | DbContext, repositories, application services |
| Transient | Every injection | Scope end | Never shared | Lightweight stateless utilities |
Common Mistakes
Mistake 1 — Injecting a Scoped service into a Singleton (captive dependency)
❌ Wrong — DbContext lifetime extended to application lifetime; state leaks between requests:
public class CacheWarmup(AppDbContext db) { } // if CacheWarmup is Singleton — bug!
✅ Correct — inject IServiceScopeFactory and create a scope explicitly in the singleton.
Mistake 2 — Using Singleton for a service that holds request-specific state
❌ Wrong — current user ID or request context stored in a Singleton field is shared across all concurrent requests.
✅ Correct — use Scoped for any service that holds or reads per-request state.