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+. IServiceProviderIsServicelets you check at startup whether a type is registered — useful for optional dependencies.IServiceProviderIsKeyedServicefor the keyed variant.- Validate scope on Build:
builder.Services.AddOptions<Foo>().Validate(...)andservices.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*
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
IDisposableservices are disposed when the scope ends. - Transient
IDisposableservices 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
IDisposableservices are disposed at app shutdown. IAsyncDisposablepreferred overIDisposablefor async cleanup; the container honors both, callingDisposeAsyncfirst.
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 injection —
public 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 isnulluntil 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
@injectis method-ish at the page level. Minimal API endpoint handlers use method injection by design:app.MapGet("/", (IFoo foo) => ...)— the framework resolvesIFooper request and passes it in. - Anti-pattern: Service Locator —
serviceProvider.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
IServiceProviderinto 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
✅ Correct: constructor injection
❌ 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
IDisposablein 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.