How DI works in ASP.NET Core

What DI in ASP.NET Core does (short)

The built-in DI container constructs and manages object graphs for you. When you register a service, you choose its lifetime — that decision determines when/how often the container creates an instance. Choosing the wrong lifetime usually results in captured dependencies, concurrency bugs, or memory / resource leaks — subtle runtime problems that are perfect weeds in senior interviews.

Lifetimes (what they mean & typical use)

  • Transient (services.AddTransient<T>())
    New instance every time requested. Use for lightweight, stateless services or short-lived operations.
  • Scoped (services.AddScoped<T>())
    One instance per scope. In an ASP.NET Core request pipeline each HTTP request defines a scope. Use for DbContext, per-request state, unit-of-work patterns.
  • Singleton (services.AddSingleton<T>())
    One instance for the lifetime of the app. Must be thread-safe and stateless (or internally synchronized). Use for caching services, configuration holders, long-lived resources.

Concrete registrations (example)

services.AddTransient<IMailer, SmtpMailer>();
services.AddScoped<AppDbContext>(); // EF Core DbContext
services.AddSingleton<IMetrics, AppMetrics>();
Code language: HTML, XML (xml)

Wrong-lifetime scenarios (detailed) — what they look like, why they’re bad, and how to fix

1) Captive dependency: Scoped injected into Singleton

Code (wrong):

public class MySingleton
{
    private readonly AppDbContext _db;          // DbContext is scoped!
    public MySingleton(AppDbContext db) => _db = db;
}
services.AddScoped<AppDbContext>();
services.AddSingleton<MySingleton>();
Code language: PHP (php)

What happens:
During singleton construction the container resolves AppDbContext from the root scope. That scoped service is effectively captured by the singleton and lives like a singleton — it is reused across requests. Consequences:

  • DbContext is not thread-safe → concurrent requests calling singleton methods that use _db will cause race conditions and unpredictable exceptions.
  • Per-request behavior (transactions, request identity) is lost — multiple users may inadvertently share the same context/transaction.
  • Hard-to-debug data integrity and concurrency issues.

Fixes:

  • Prefer not to capture a scoped service in a singleton. Instead resolve the scoped service per operation using a scope factory:
public class MySingleton
{
    private readonly IServiceScopeFactory _scopeFactory;
    public MySingleton(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;

    public void DoWork()
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        // use db here safely per-operation
    }
}
Code language: PHP (php)
  • Or inject a factory Func<AppDbContext> or a dedicated factory type that creates a new DbContext per call.

Interview line to say:
“Never inject scoped services into singletons directly — do per-operation resolution using IServiceScopeFactory or a factory delegate so each request gets its own instance.”


2) Singleton depends on stateful service (user-specific)

Code (wrong):

services.AddSingleton<UserSessionService>(); // holds current user's id in a field
Code language: JavaScript (javascript)

What happens:
Singletons are shared between requests. Storing request/user-specific data in singleton fields causes data leakage across requests — a catastrophic privacy bug.

Fix:
Make the service Scoped, or make the singleton stateless and accept user identity as a method parameter, or store per-user data in a per-request store (e.g., HttpContext.Items) or a distributed cache keyed by session id.

Interview line:
“Singletons must not store request-specific state. If you need per-request state, use scoped services or pass data into method calls.”


3) DbContext registered as Singleton

Code (wrong):

services.AddSingleton<AppDbContext>();
Code language: HTML, XML (xml)

Why wrong:
DbContext assumes per-scope lifetime. Singletons share one instance across threads; EF Core DbContext is not thread-safe, leading to concurrency issues, stale tracking state, and often exceptions like InvalidOperationException when used simultaneously.

Fix:
services.AddDbContext<AppDbContext>(ServiceLifetime.Scoped) — default is scoped.

Interview line:
“EF Core DbContext should always be scoped. Making it singleton breaks its internal change tracking and threading assumptions.”


4) Transient heavy object captured as singleton

Code (subtle bug):

services.AddTransient<HeavyParser>();
services.AddSingleton<SingletonService>(sp => new SingletonService(sp.GetRequiredService<HeavyParser>()));
Code language: HTML, XML (xml)

What happens:
The transient HeavyParser resolves once during the singleton factory and is reused forever — losing transient semantics. If HeavyParser holds per-call state or depends on other scoped services, you get misbehavior.

Fix:
Inject a factory or IServiceProvider and resolve per-call:

public SingletonService(Func<HeavyParser> parserFactory) { ... }
Code language: HTML, XML (xml)

or via IServiceProvider with create scope when needed.


5) Singleton HttpClient vs raw static HttpClient

Rule of thumb: use IHttpClientFactory (registered as singleton infrastructure) to manage HttpClient lifetimes and DNS rotation. Don’t manually use a new HttpClient per request or hold a stale one with old DNS.

Interview line:
“Use IHttpClientFactory to get configured HttpClient instances to avoid socket exhaustion and DNS stale issues.”


Patterns to resolve lifetime mismatches (practical options)

  1. IServiceScopeFactory.CreateScope() — create a scope inside a singleton method, resolve scoped services from that scope, dispose after use.
  2. Factory delegateservices.AddTransient<Func<MyScopedService>>(sp => () => sp.GetRequiredService<MyScopedService>()); inject Func<MyScopedService> and call when needed.
  3. Lazy / Func wrappers — delay creation until method call.
  4. Design change — make the singleton stateless and accept the scoped objects as parameters from caller (if caller is scoped).
  5. BackgroundService / IHostedService: if a background singleton worker needs scoped services, create a scope inside the worker loop for each unit of work:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        // work with db
    }
}
Code language: JavaScript (javascript)

Thread-safety and disposal notes

  • Singletons must be thread-safe: any mutable state needs locking or concurrent structures. Immutable objects are ideal.
  • Be careful with IDisposable services: the container disposes singletons when the provider is disposed (app shutdown); scoped disposables are disposed when the scope ends.
  • Avoid holding references to IServiceProvider and resolving everything from it all the time — prefer explicit dependencies or small well-defined factories for clarity and testability.

Example: bad -> fixed end-to-end

Bad:

public class ReportGenerator
{
    private readonly AppDbContext _db;
    public ReportGenerator(AppDbContext db) => _db = db; // captured scoped into singleton

    public IEnumerable<Report> Generate() => _db.Reports.ToList(); // concurrency hazard
}
services.AddScoped<AppDbContext>();
services.AddSingleton<ReportGenerator>();
Code language: PHP (php)

Fixed:

public class ReportGenerator
{
    private readonly IServiceScopeFactory _scopeFactory;
    public ReportGenerator(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;

    public IEnumerable<Report> Generate()
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        return db.Reports.AsNoTracking().ToList();
    }
}
services.AddScoped<AppDbContext>();
services.AddSingleton<ReportGenerator>();
Code language: PHP (php)

How to explain this in an interview (concise script)

“Dependency injection lifetimes control how often instances are created. Transient: every request; Scoped: one per logical scope (HTTP request in web apps); Singleton: one for app lifetime. A frequent mistake is capturing a scoped service in a singleton — for example, injecting DbContext into a singleton — which effectively promotes the scoped instance to behave like a singleton. Because DbContext is not thread-safe and holds per-request tracking state, this results in concurrency bugs and stale or cross-request data. The proper patterns are: make the service scoped if it needs per-request data, or resolve scoped dependencies per operation using IServiceScopeFactory (or an injected factory) so each operation gets a fresh instance. Also prefer IHttpClientFactory for outbound HTTP, and avoid storing request-specific state in singletons.”

Quick checklist (for senior-level answers)

  • State lifetimes and their intended uses.
  • Describe captured dependency problem explicitly.
  • Demonstrate a code fix: IServiceScopeFactory.CreateScope() or Func<T> factory.
  • Mention thread-safety and DbContext specifics.
  • Mention IHttpClientFactory and options pattern as best-practice.
  • Explain trade-offs (simplicity vs safety) and why your fix is safe for concurrency.

Leave a Comment