Service Lifetimes — Transient, Scoped and Singleton

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>();
Note: 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.
Tip: Enable DI validation on startup to catch lifetime mismatches before the first request: set 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.
Warning: The captive dependency bug: a longer-lived service holds a reference to a shorter-lived service, extending the shorter-lived service’s lifetime beyond its intention. The most dangerous case: a Singleton captures a Scoped DbContext. The DbContext is created once and reused across all requests — its change tracker accumulates state across requests, causing one user to see another’s data. ASP.NET Core’s DI validation detects this: “Cannot consume scoped service ‘AppDbContext’ from singleton ‘ICacheService’.”

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.

🧠 Test Yourself

Two concurrent requests arrive simultaneously. Both use PostService registered as Scoped. Do they share the same PostService instance?