Skip to content

Dependency Injection

Key Points

  • Three lifetimes: Singleton (one per app), Scoped (one per request/scope), Transient (new every resolve). Pick by ownership semantics, not by perf.
  • Captive dependencies — when a longer-lived service captures a shorter-lived one — are the #1 DI bug. Singleton holding scoped DbContext = leak / stale data.
  • Keyed services (AddKeyedSingleton, [FromKeyedServices("k")]) — multiple registrations of the same interface, distinguished by key. .NET 8+.
  • IServiceProviderIsService lets you check at startup whether a type is registered — useful for optional dependencies.
  • IServiceProviderIsKeyedService for the keyed variant.
  • Validate scope on Build: builder.Services.AddOptions<Foo>().Validate(...) and services.BuildServiceProvider(validateScopes: true).
  • Inject only what you use. Don't take IServiceProvider (service locator anti-pattern). Use constructor injection.

Concepts (deep dive)

Three lifetimes

builder.Services.AddSingleton<ICache, MemoryCache>();        // 1 instance for app
builder.Services.AddScoped<DbContext, MyDbContext>();        // 1 per HTTP request
builder.Services.AddTransient<IGuidProvider, GuidProvider>(); // new each resolve
Lifetime Lifecycle Disposed
Singleton Created once at first resolve (or eagerly); lives until app shutdown At app shutdown
Scoped Created once per scope (typically per HTTP request); lives until scope disposes At scope dispose
Transient Created every time GetService is called If IDisposable, by the scope that created it

ASP.NET Core wraps each request in a scope. Scoped services live exactly the request's lifetime. The container disposes them when the request ends.

Captive dependencies — the canonical bug

public class Cache(MyDbContext db) { ... }   // Cache will be singleton

builder.Services.AddSingleton<Cache>();
builder.Services.AddScoped<MyDbContext>();
// ❌ Cache (singleton) captures DbContext (scoped) — DbContext lives forever!

The container can resolve this combination but the resulting Cache holds the first DbContext it ever saw. That DbContext: - Never gets disposed. - Holds connections forever. - Sees stale change-tracker state. - Won't reflect changes made by other requests.

Detection: builder.Services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }) — but ASP.NET Core already enables this in development by default. Promotes errors at startup. Don't disable it.

Fixes:

// Option 1: re-scope on use
public class Cache(IServiceScopeFactory scopeFactory)
{
    public async Task<X> GetAsync()
    {
        await using var scope = scopeFactory.CreateAsyncScope();
        var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();
        return await db.Foo.SingleAsync();
    }
}

// Option 2: pool the resource (for DbContext, EF Core's DbContext pool)
builder.Services.AddDbContextPool<MyDbContext>(opt => opt.UseSqlServer(connStr));

Keyed services (.NET 8+)

builder.Services.AddKeyedSingleton<IPaymentProvider, StripeProvider>("stripe");
builder.Services.AddKeyedSingleton<IPaymentProvider, PayPalProvider>("paypal");

public class CheckoutService(
    [FromKeyedServices("stripe")] IPaymentProvider stripe,
    [FromKeyedServices("paypal")] IPaymentProvider paypal)
{ ... }

// Or resolve dynamically:
public class GatewayRouter(IServiceProvider sp)
{
    public IPaymentProvider Pick(string key) => sp.GetRequiredKeyedService<IPaymentProvider>(key);
}

Useful for strategy patterns, multi-tenant configurations, polymorphism by name. Replaces hand-rolled factory dictionaries.

IEnumerable<T> injection

builder.Services.AddSingleton<IValidator, NameValidator>();
builder.Services.AddSingleton<IValidator, AgeValidator>();

public class Service(IEnumerable<IValidator> validators)
{
    public bool Valid(Customer c) => validators.All(v => v.Check(c));
}

When you register multiple of the same type, inject IEnumerable<T> to get all. Order is registration order.

IServiceProviderIsService for optional dependencies

public class Reporter(IServiceProvider sp, IServiceProviderIsService check)
{
    public void Report()
    {
        if (check.IsService(typeof(IFax)))
        {
            var fax = sp.GetRequiredService<IFax>();
            fax.Send();
        }
    }
}

Lets you query at runtime whether a type is registered — useful for libraries with optional integrations. Service locator pattern is generally discouraged but this is a legitimate use.

Owned<T> / explicit factories

EF Core's IDbContextFactory<TContext> (registered via AddDbContextFactory) is the right pattern when you want to own the lifetime explicitly:

public class Worker(IDbContextFactory<MyDbContext> factory)
{
    public async Task DoAsync()
    {
        await using var db = factory.CreateDbContext();
        // db is yours; dispose when done
    }
}

Useful in singletons, hosted services, anywhere request-scoping doesn't fit.

TryAdd*

builder.Services.TryAddSingleton<IFoo, DefaultFoo>();
// only registers if no IFoo registered yet

Library convention: register defaults via TryAdd* so consumer overrides work:

public static IServiceCollection AddMyLibrary(this IServiceCollection services)
{
    services.TryAddSingleton<IFoo, DefaultFoo>();
    return services;
}

If consumer registered IFoo before calling AddMyLibrary, their version wins.

Decorator pattern

// Original:
builder.Services.AddSingleton<IService, RealService>();

// Wrap with logging:
builder.Services.Decorate<IService, LoggingDecorator>();
// LoggingDecorator constructor takes IService; gets the previously-registered RealService

Decorate is from Scrutor (a popular OSS library). The MS DI container doesn't have built-in decoration, but Scrutor makes it idiomatic. Common for logging, caching, retries — wrapping without modifying.

Validating registrations on build

var sp = builder.Services.BuildServiceProvider(new ServiceProviderOptions
{
    ValidateScopes = true,        // detect captive dependencies
    ValidateOnBuild = true        // resolve every registered service to surface missing deps
});

ASP.NET Core hosts enable both in Development by default. Don't disable. Catches misconfigurations at startup, not at the first request.

Generic type registration

builder.Services.AddSingleton(typeof(IRepository<>), typeof(GenericRepository<>));
// Now IRepository<Customer> and IRepository<Order> both resolve

Open generic registration: one entry that satisfies any closed-generic.

Disposal semantics

  • Scoped IDisposable services are disposed when the scope ends.
  • Transient IDisposable services are disposed when the creating scope ends — they're tracked by the scope that resolved them. Don't store transients in singletons; they leak.
  • Singleton IDisposable services are disposed at app shutdown.
  • IAsyncDisposable preferred over IDisposable for async cleanup; the container honors both, calling DisposeAsync first.

Typical bad patterns

// ❌ Service locator anti-pattern
public class Bad(IServiceProvider sp)
{
    public void Do()
    {
        var foo = sp.GetService<IFoo>();   // hides dependency
    }
}

// ❌ Singleton with state captured per request
public class Counter
{
    public int Value { get; set; }   // shared across requests = race / wrong values
}
builder.Services.AddSingleton<Counter>();

// ❌ AddTransient<DbContext>
builder.Services.AddTransient<MyDbContext>();   // new context per resolve = state chaos

DI variants — constructor, property, method injection

Three flavors of dependency injection. The built-in MS DI container only does constructor; the others require third-party containers or specific framework hooks.

  • Constructor injection — the canonical .NET pattern. Required dependencies passed via the ctor; the only style the built-in container supports natively. ✅ Compile-time checked, fails fast at startup, immutable, easy to test (just new Service(mockFoo)). Use this by default.
  • Property injectionpublic IFoo Foo { get; set; } populated by a third-party container (Autofac, Castle Windsor). Used for optional dependencies. The MS container does not do this. ⚠️ Allows partially-constructed objects (Foo is null until populated); harder to reason about lifecycle. Generally avoid except for circular-dependency workarounds — and prefer fixing the cycle.
  • Method injection — pass the dependency as a method parameter at the call site. Used when (a) only one method needs it, or (b) the dependency varies per call. Razor's @inject is method-ish at the page level. Minimal API endpoint handlers use method injection by design: app.MapGet("/", (IFoo foo) => ...) — the framework resolves IFoo per request and passes it in.
  • Anti-pattern: Service LocatorserviceProvider.GetRequiredService<T>() scattered through domain code. Hides dependencies; defeats compile-time safety; makes tests painful. Acceptable only in framework-bridge code (factories, hosted-service wiring, dynamic resolution by key) — never in domain services.
// ✅ Constructor — canonical
public class OrderService(IOrderRepo repo, IClock clock)
{
    public Task PlaceAsync(Order o) => repo.AddAsync(o with { Placed = clock.UtcNow });
}

// ⚠️ Property — only for genuinely optional deps, third-party container required
public class Reporter
{
    public IOptionalAuditor? Auditor { get; set; }   // populated by Autofac if registered
    public void Run() { /* ... */ Auditor?.Log(); }
}

// ✅ Method injection — Minimal API handler
app.MapGet("/orders/{id:int}", (int id, IOrderRepo repo) => repo.FindAsync(id));

// ❌ Service locator inside a domain service
public class BadOrderService(IServiceProvider sp)   // hides what it actually needs
{
    public Task PlaceAsync(Order o)
        => sp.GetRequiredService<IOrderRepo>().AddAsync(o);   // surprise dependency at runtime
}

💡 If you're tempted to inject IServiceProvider into a domain service, that's a signal the design is wrong. Inject the actual dependencies, or split the class.


Code: correct vs wrong

❌ Wrong: scoped in singleton

public class Cache(MyDbContext db) { ... }
builder.Services.AddSingleton<Cache>();
builder.Services.AddScoped<MyDbContext>();
// captive: DbContext never released

✅ Correct: factory or scope

builder.Services.AddDbContextFactory<MyDbContext>(opt => opt.UseSqlServer(...));

public class Cache(IDbContextFactory<MyDbContext> factory)
{
    public async Task DoAsync()
    {
        await using var db = factory.CreateDbContext();
        /* ... */
    }
}

❌ Wrong: service locator

public class Bad(IServiceProvider sp)
{
    public void Do() => sp.GetRequiredService<IFoo>().Run();
}

✅ Correct: constructor injection

public class Good(IFoo foo)
{
    public void Do() => foo.Run();
}

❌ Wrong: assuming singleton can hold per-request state

public class TenantHolder
{
    public string? CurrentTenant { get; set; }   // ❌ shared across requests
}
builder.Services.AddSingleton<TenantHolder>();

✅ Correct: scoped or AsyncLocal

public class TenantContext
{
    public string? Current { get; set; }
}
builder.Services.AddScoped<TenantContext>();

Design patterns for this topic

Pattern 1 — "Constructor injection only"

  • Intent: explicit dependencies; testable.

Pattern 2 — "Keyed services for strategy"

  • Intent: multiple implementations distinguished by name.

Pattern 3 — "IDbContextFactory over scoped DbContext for non-request workers"

  • Intent: explicit lifetime in singletons / hosted services.

Pattern 4 — "TryAdd* in library extensions"

  • Intent: consumer overrides win.

Pattern 5 — "Decorator (Scrutor) for cross-cutting"

  • Intent: wrap with logging/caching/retries without modifying.

Pros & cons / trade-offs

Aspect Pros Cons
Built-in MS DI Zero deps; sufficient No decorate / advanced features
Autofac / Lamar Decoration, modules, advanced Extra dep
Constructor injection Explicit Many params for service-heavy classes
Service locator Hides deps Anti-pattern; obscures coupling
Keyed services Strategy-by-name Newer; API surface still maturing

When to use / when to avoid

  • Use constructor injection by default.
  • Use scoped for per-request state (DbContext, request context).
  • Use singleton for stateless or thread-safe shared services.
  • Use transient for stateless lightweight things (factories, validators).
  • Use keyed services for strategy-by-name.
  • Avoid service locator — it's an anti-pattern.
  • Avoid scoped-in-singleton — captive dependency.

Interview Q&A

Q1. Three DI lifetimes? Singleton (one per app), Scoped (one per request), Transient (new each resolve).

Q2. What's a captive dependency? Longer-lived service holds reference to shorter-lived. Singleton holds Scoped → that scoped service is captured forever. Symptoms: leaks, stale state, race conditions.

Q3. How does ASP.NET Core wrap requests? The framework creates a IServiceScope per request. Scoped services live the request's lifetime; disposed at end.

Q4. What's a keyed service? A registration distinguished by key. Resolve via [FromKeyedServices("k")] or IServiceProvider.GetKeyedService<T>("k"). .NET 8+.

Q5. How do you inject DbContext into a singleton? Don't directly. Use IDbContextFactory<T> (registered via AddDbContextFactory) and create contexts on demand.

Q6. What's IServiceProviderIsService? Service that lets you check whether a type is registered, without resolving. Useful for optional integrations.

Q7. When does ValidateScopes matter? At BuildServiceProvider time. Detects captive dependencies. Default true in Development; off in Production for perf — but ValidateOnBuild (off by default) catches them on startup.

Q8. How do you register multiple implementations of the same interface? Multiple Add* calls. Inject as IEnumerable<T> to get all. Or use keyed services.

Q9. What's TryAddSingleton? Registers only if not already registered. Library convention to allow consumer overrides.

Q10. What's the decorator pattern in DI? Wrap an existing service with another that has the same interface. The MS container doesn't support out-of-the-box; Scrutor adds Decorate.

Q11. When are transient IDisposable services disposed? By the scope that resolved them. If a singleton resolves a transient, the singleton's lifetime owns it — the transient lives forever. Avoid.

Q12. What's the cost of DI resolution? Resolution is fast (microseconds), but every constructor parameter resolves recursively. Heavy graphs (50+ params/levels) add up; profile if it shows in startup.


Gotchas / common mistakes

  • ⚠️ Captive scoped-in-singleton — leaks, stale state.
  • ⚠️ Transient IDisposable in singleton — never disposed.
  • ⚠️ Service locator (sp.GetService<T>()) — anti-pattern.
  • ⚠️ AddTransient<DbContext>() — change-tracker chaos.
  • ⚠️ Disposing a scoped service yourself — container disposes again at scope end → double-dispose.
  • ⚠️ Mutable singleton state — shared across requests; race conditions.

Further reading