Dependency Injection in ASP.NET Core
A deep-dive walkthrough of ASP.NET Core's built-in dependency injection container — covering the three service lifetimes (Transient, Scoped, Singleton) in depth, registration patterns, how a request's scope actually works, the captive dependency problem and scope validation, constructor injection mechanics, IServiceProvider and service resolution, options pattern integration, and the specific, common mistakes that come from misunderstanding lifetime interactions in a real ASP.NET Core application.
Table of Contents
- Introduction
- Dependency Injection as a Pattern, Briefly Revisited
- The Built-In Container: IServiceCollection and IServiceProvider
- Registering Services: The Basic Syntax
- The Three Lifetimes, In Depth
- How a Request's Scope Actually Works
- Constructor Injection: How Resolution Actually Happens
- The Captive Dependency Problem
- Scope Validation: Catching Captive Dependencies Automatically
- Registering Multiple Implementations of the Same Interface
- Factory Registration and Registering Concrete Types
- IServiceScopeFactory: Creating Scopes Manually
- The Options Pattern: DI-Integrated Configuration
- Disposal: How the Container Cleans Up After Itself
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
ASP.NET Core has dependency injection built directly into the framework, not bolted on as an optional third-party library — every controller, minimal API endpoint, middleware component, and background service is expected to receive its dependencies through the container rather than constructing them directly. This series' Interfaces guide covers dependency injection as a general pattern (Section 10) — depending on abstractions, supplied from outside via the constructor; this guide goes deep specifically on ASP.NET Core's own container: how service lifetimes work, how a request's scope is actually created and torn down, and the captive dependency problem, which is far and away the most common, most subtle mistake developers make once an application has more than a handful of services with genuinely different lifetimes.
Program.cs (composition root):
builder.Services.AddTransient<IEmailSender, SendGridEmailSender>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();
Anywhere in the app: request these through a CONSTRUCTOR parameter —
the container resolves and supplies the concrete instance automatically,
according to whichever lifetime it was registered with.
1. Dependency Injection as a Pattern, Briefly Revisited
The core idea, as this series' Interfaces guide's Section 4 already establishes
public class OrderService
{
private readonly IEmailSender _emailSender; // depends on the ABSTRACTION, not a concrete class
public OrderService(IEmailSender emailSender) => _emailSender = emailSender; // supplied from OUTSIDE
}
This series' Interfaces guide covers the general principle in depth: a class declares what it needs via constructor parameters, and something external is responsible for supplying concrete implementations — the loose coupling this achieves (testability, swappable implementations) applies identically here. What this guide adds is everything specific to ASP.NET Core's own container — how it decides which concrete instance to hand you, and, critically, how long that instance lives.
What ASP.NET Core's built-in container specifically provides
A registry (IServiceCollection) where you declare "when something asks
for THIS interface, give it THIS concrete implementation" — plus a
resolver (IServiceProvider) that actually constructs and hands out
those implementations, automatically supplying THEIR OWN dependencies
recursively, and tracking lifetime and disposal along the way.
This is the concrete machinery this whole guide is about — not dependency injection as a concept (already covered), but the specific container ASP.NET Core ships with, registers services into during startup, and resolves from on every request.
2. The Built-In Container: IServiceCollection and IServiceProvider
IServiceCollection: the registry you configure at startup
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>(); // builder.Services IS an IServiceCollection
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();
var app = builder.Build(); // AFTER this call, the IServiceCollection is compiled into an IServiceProvider
builder.Services is the IServiceCollection — essentially a list of service registrations, each one saying "for this type (usually an interface), construct instances this way, with this lifetime." This registration happens once, during application startup, before the app actually begins serving requests.
IServiceProvider: the resolver, built once from the completed registrations
IServiceProvider provider = app.Services; // the BUILT container — this is what actually resolves services
var orderRepo = provider.GetService<IOrderRepository>(); // resolves an instance, following its registered lifetime
Once builder.Build() runs, the collection of registrations is compiled into an IServiceProvider — the actual, functioning container capable of resolving service instances. In ordinary application code, you almost never call GetService<T>() directly (Section 6 covers why constructor injection is the idiomatic path instead) — but understanding that this resolver object exists, and is what the framework consults every time it needs to construct a controller or invoke a minimal API endpoint, is foundational to everything else in this guide.
3. Registering Services: The Basic Syntax
The three core registration methods, one per lifetime
builder.Services.AddTransient<IEmailSender, SendGridEmailSender>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();
Each of these tells the container: "when something asks for the interface (first type parameter), construct an instance of the concrete class (second type parameter), and manage its lifetime according to this specific method's rules" — Section 4 covers exactly what each lifetime means; this section is purely about the registration syntax itself.
Registering a concrete type directly, without an interface
builder.Services.AddScoped<OrderProcessor>(); // no interface — just a concrete class, registered as itself
Not every registered service needs an interface — for a class with no meaningful alternative implementation (nothing to swap it for, no need to mock it independently in tests), registering the concrete type directly is entirely valid and common; the interface-based pattern from Section 3's first examples is specifically for cases where this series' Interfaces guide's loose-coupling benefits (swappability, testability) genuinely matter.
TryAdd variants: registering only if nothing has already claimed that service
builder.Services.TryAddScoped<IOrderRepository, SqlOrderRepository>(); // only registers if NOT already registered
TryAddScoped/TryAddTransient/TryAddSingleton are useful specifically in library or extension-method code that wants to provide a sensible default registration without overriding one the consuming application may have already supplied — a common pattern in reusable service-registration extension methods (services.AddMyLibrary()) that shouldn't clobber a registration the calling application deliberately configured differently.
4. The Three Lifetimes, In Depth
Transient: a brand-new instance, every single time it's requested
builder.Services.AddTransient<IEmailSender, SendGridEmailSender>();
// Every constructor parameter asking for IEmailSender gets its OWN, separate instance —
// even TWO parameters of type IEmailSender within the SAME class construction get DIFFERENT instances
Transient means exactly what it sounds like: a fresh instance is created on every single resolution request, with no sharing at all — not even within the same object graph being constructed for a single request. This is the right default for lightweight, stateless services with no meaningful shared state and no expensive construction cost.
Scoped: one instance per scope — in a web application, that's one instance per HTTP request
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
// Within ONE HTTP request, EVERY component asking for IOrderRepository gets the SAME instance.
// A DIFFERENT, concurrent HTTP request gets ITS OWN, separate instance.
Scoped means one instance is created and shared for the duration of a scope — Section 5 covers exactly what creates and ends a scope, but in the overwhelmingly common ASP.NET Core case, a scope corresponds precisely to one HTTP request: every service resolved within the handling of a single request that asks for the same scoped service gets the identical instance, while a separate, concurrent request gets its own, entirely independent one. This is the standard, idiomatic lifetime for anything tied to "one unit of work" — a database context (Entity Framework's DbContext is registered scoped by convention, specifically so an entire request shares one consistent unit-of-work/change-tracking context) is the textbook example.
Singleton: exactly one instance, for the entire lifetime of the application
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();
// EVERY request, EVERY component, for the ENTIRE lifetime of the running application,
// shares the EXACT SAME instance.
Singleton means one instance is created the first time it's requested (or, optionally, eagerly at startup) and then reused for every subsequent resolution, across every request, for as long as the application process runs. This is appropriate for genuinely shared, application-wide state — an in-memory cache, a configuration object read once at startup, a connection pool manager — but, per Section 7, it comes with a genuine, serious trap when a singleton depends on something with a shorter lifetime.
A side-by-side comparison, to make the distinction concrete
public class DemoController : ControllerBase
{
public DemoController(
ITransientService transient1, ITransientService transient2, // DIFFERENT instances
IScopedService scoped1, IScopedService scoped2, // SAME instance (within this request)
ISingletonService singleton1, ISingletonService singleton2) // SAME instance (across ALL requests, ever)
{
Console.WriteLine(ReferenceEquals(transient1, transient2)); // false
Console.WriteLine(ReferenceEquals(scoped1, scoped2)); // true
Console.WriteLine(ReferenceEquals(singleton1, singleton2)); // true
}
}
This is the clearest, most direct way to internalize the three lifetimes' actual, observable behavior — worth running this exact experiment once in a real project, since seeing ReferenceEquals return true or false in practice tends to cement the distinction far better than the definitions alone.
5. How a Request's Scope Actually Works
ASP.NET Core creates a new scope at the start of every incoming HTTP request
Incoming request arrives → ASP.NET Core's middleware pipeline creates a
NEW IServiceScope, specifically for this request → every scoped
service resolved DURING this request's handling comes from THIS scope
→ once the response is sent and the request completes, the scope is
DISPOSED, and every scoped (and transient) IDisposable service
resolved within it is disposed along with it (Section 13).
This is the mechanical reality underneath Section 4's "one instance per HTTP request" description of Scoped — it's not a special case the framework hardcodes specifically for "requests"; it's the general scope mechanism (IServiceScopeFactory, Section 11), applied automatically by ASP.NET Core's request pipeline, once per request, entirely transparently to your own code in the common case.
HttpContext.RequestServices: the request's own scoped service provider
IServiceProvider requestScopedProvider = HttpContext.RequestServices;
var repo = requestScopedProvider.GetService<IOrderRepository>(); // resolves from THIS request's scope specifically
Worth knowing this exists, even though you rarely need to touch it directly (constructor injection, Section 6, handles this automatically for controllers and most framework-integrated components) — HttpContext.RequestServices is the actual, concrete IServiceProvider scoped to the current request, and it's what the framework itself uses internally when constructing your controllers and resolving their dependencies.
6. Constructor Injection: How Resolution Actually Happens
The framework inspects a class's constructor and resolves each parameter automatically
public class OrdersController : ControllerBase
{
private readonly IOrderRepository _repository;
private readonly IEmailSender _emailSender;
public OrdersController(IOrderRepository repository, IEmailSender emailSender)
{
_repository = repository; // the FRAMEWORK supplied this — you never called `new SqlOrderRepository()`
_emailSender = emailSender;
}
}
When ASP.NET Core needs to construct OrdersController to handle a request, it inspects the constructor's parameters, resolves each one from the container (following each service's registered lifetime), and passes the resolved instances in — this is genuinely automatic; you never call new OrdersController(...) yourself anywhere in application code. This is the idiomatic, overwhelmingly preferred way to consume services in ASP.NET Core, in contrast to manually calling GetService<T>() (Section 2), which is reserved for the narrower cases where constructor injection genuinely isn't available (Section 11 covers one such case).
Recursive resolution: a service's own dependencies are resolved the same way
public class OrderService
{
public OrderService(IOrderRepository repository, IEmailSender emailSender) { /* ... */ } // ALSO injected
}
public class OrdersController : ControllerBase
{
public OrdersController(OrderService orderService) { /* ... */ } // the container builds the WHOLE graph
}
Resolving OrdersController doesn't stop at its own direct parameters — if OrderService itself has constructor dependencies, the container resolves those too, recursively, building out the entire object graph automatically. This is what makes dependency injection genuinely scale to large applications with deep service hierarchies without every layer needing to manually wire up its own dependencies' dependencies.
What happens if a required service was never registered
// If IEmailSender was NEVER registered with builder.Services, this throws at RUNTIME
// (specifically, the first time something requiring it is resolved — often at application
// startup if eager validation is configured, per Section 8, or otherwise the first request that needs it):
// InvalidOperationException: Unable to resolve service for type 'IEmailSender' ...
This is worth knowing as a genuine, common early-development error — an unregistered but required dependency doesn't fail silently or produce a null; it throws a clear, specific InvalidOperationException naming exactly which service couldn't be resolved, which is usually enough to diagnose the missing registration immediately.
7. The Captive Dependency Problem
The setup: a longer-lived service holding a reference to a shorter-lived one
public class CachingService // registered as SINGLETON
{
private readonly IOrderRepository _repository; // registered as SCOPED
public CachingService(IOrderRepository repository) // ❌ a SINGLETON depending on a SCOPED service
{
_repository = repository; // this scoped instance is now held by a SINGLETON
}
}
builder.Services.AddSingleton<CachingService>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
This is the single most important, most commonly encountered mistake in ASP.NET Core dependency injection, and it deserves its own full section rather than a line in the pitfalls table, because understanding why it's dangerous requires understanding exactly what happens mechanically.
Why this is genuinely dangerous, mechanically
CachingService is constructed ONCE, the very first time anything needs it
— and at THAT moment, the container resolves an IOrderRepository
instance FOR IT, from WHATEVER scope happens to be active at that
moment (often the very first request's scope, or, worse, no scope at
all if constructed eagerly at startup). CachingService then holds ONTO
that specific IOrderRepository instance FOREVER, since CachingService
itself lives for the application's entire lifetime.
EVERY SUBSEQUENT REQUEST that goes through CachingService is now using
a SCOPED IOrderRepository that was created for a COMPLETELY DIFFERENT,
possibly LONG-SINCE-COMPLETED request — its underlying DbContext (if
that's what it wraps) may already be disposed, produce stale or
incorrect data, or, in genuinely concurrent scenarios, be used by
multiple requests SIMULTANEOUSLY despite never being designed for that
(this series' Threading guide's Section 3 race-condition concerns apply
directly, since a scoped service is emphatically not meant to be shared
across concurrent requests).
This is called a captive dependency: the shorter-lived (scoped) service has been "captured" by the longer-lived (singleton) one, and is now living far longer than its registered lifetime was ever designed for — with consequences ranging from stale data to genuine thread-safety violations, all stemming from a registration mistake that's easy to make and, without help, easy to miss until it causes a genuinely confusing production bug.
The general rule: a service should never depend on something with a SHORTER lifetime
Singleton → can safely depend on: Singleton ONLY
Scoped → can safely depend on: Scoped or Singleton
Transient → can safely depend on: Transient, Scoped, or Singleton (transient is the SHORTEST-lived)
This is the complete, general rule worth memorizing: a service can only safely depend on something with an equal or longer lifetime than its own — a Singleton depending on a Scoped or Transient service is always the captive dependency problem; a Scoped service depending on a Transient one is fine (a fresh transient instance per resolution, held only for the scope's duration, is perfectly safe); everything can safely depend on a Singleton, since Singletons genuinely do live for the whole application's duration regardless of who holds a reference to them.
8. Scope Validation: Catching Captive Dependencies Automatically
ASP.NET Core can validate this automatically, and does by default in the Development environment
var builder = WebApplication.CreateBuilder(args);
// In Development, ASP.NET Core automatically enables:
// ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }
This is genuinely good news worth knowing explicitly: ASP.NET Core's WebApplication.CreateBuilder automatically turns on scope validation when running in the Development environment — ValidateScopes causes the container to actively check for exactly Section 7's captive dependency pattern and throw a clear exception the moment it happens, rather than silently allowing it and letting the bug manifest confusingly later.
What the validation exception actually looks like
InvalidOperationException: Cannot consume scoped service 'IOrderRepository'
from singleton 'CachingService'.
This is a genuinely clear, actionable error message — it names both services involved and states the exact problem, precisely because this specific mistake is common enough that the framework authors built dedicated, explicit detection for it rather than leaving developers to discover it via mysterious production symptoms.
Explicitly enabling this validation in other environments, for extra safety
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true; // catches SOME captive dependency issues even at STARTUP, before any request
});
Worth knowing this validation is specifically a Development-environment default — it's not automatically active in Production (partly for a small performance reason, and partly because a captive dependency ideally should have already been caught during development/testing) — some teams deliberately enable it in Staging or even Production as well, trading a small, bounded validation cost for the certainty that this specific class of bug simply cannot reach real users undetected.
9. Registering Multiple Implementations of the Same Interface
The container supports registering several implementations of the same interface
builder.Services.AddScoped<INotificationChannel, EmailNotificationChannel>();
builder.Services.AddScoped<INotificationChannel, SmsNotificationChannel>();
builder.Services.AddScoped<INotificationChannel, PushNotificationChannel>();
Multiple registrations for the same interface are entirely valid — they don't overwrite each other; each is added to the container's internal registry alongside the others.
Resolving all of them at once via IEnumerable<T>
public class NotificationDispatcher
{
private readonly IEnumerable<INotificationChannel> _channels;
public NotificationDispatcher(IEnumerable<INotificationChannel> channels) => _channels = channels;
public async Task NotifyAllAsync(string message)
{
foreach (var channel in _channels) // EVERY registered INotificationChannel implementation
await channel.SendAsync(message);
}
}
Requesting IEnumerable<INotificationChannel> (rather than just INotificationChannel directly) resolves every registered implementation, in registration order, as a collection — this is a genuinely useful pattern for exactly this kind of fan-out scenario (notify through every available channel), directly related to this series' Interfaces guide's Section 11 Observer pattern discussion, just resolved automatically through the container rather than wired up manually.
Resolving just the LAST registered implementation, if a single instance is requested instead
public class SomeService
{
// If registered as above, this resolves ONLY PushNotificationChannel (the LAST one registered) —
// the earlier registrations are still present for IEnumerable<T> resolution, but a SINGLE
// INotificationChannel request only ever gets the most recently registered one
public SomeService(INotificationChannel channel) { }
}
Worth knowing this specific, sometimes-surprising behavior: if multiple implementations are registered and something requests a single instance of the interface (not the IEnumerable<T> form), the container hands back the last one registered — this is a real, if narrow, source of confusion when a developer registers several implementations expecting the container to somehow pick "the right one" contextually, which it does not; it simply defaults to the last registration for single-instance resolution.
10. Factory Registration and Registering Concrete Types
Registering with a factory delegate, for services needing custom construction logic
builder.Services.AddScoped<IEmailSender>(serviceProvider =>
{
var config = serviceProvider.GetRequiredService<IConfiguration>(); // resolve OTHER services during construction
var apiKey = config["EmailProvider:ApiKey"];
return new SendGridEmailSender(apiKey);
});
The Func<IServiceProvider, T> overload of the registration methods lets you supply custom construction logic — genuinely useful when a service's constructor needs a value that isn't itself a registered service (a configuration value, say) rather than another injectable dependency, or when construction requires conditional logic the container's automatic constructor-parameter resolution can't express on its own.
Registering an already-constructed instance directly
var sharedCache = new MemoryCache(new MemoryCacheOptions());
builder.Services.AddSingleton<IMemoryCache>(sharedCache); // register the SPECIFIC, already-created instance
For a genuinely singleton object that already exists (perhaps constructed earlier in Program.cs for some other reason), registering the specific instance directly — rather than letting the container construct a new one — is a valid, if less common, registration form; worth knowing it's available for exactly this narrow case.
11. IServiceScopeFactory: Creating Scopes Manually
The problem: some code runs outside any HTTP request, with no ambient scope to use
A background worker (per this series' Background Services guide), a
message queue consumer, or code running on a timer has NO incoming
HTTP request — there's no automatically-created request scope (Section
5) for it to resolve scoped services from.
This is a genuinely common, real situation: any code that runs outside the request pipeline needs its own explicit mechanism to create a scope if it wants to use scoped services (like a DbContext) safely, since there's no request lifecycle automatically providing one.
Creating a scope explicitly with IServiceScopeFactory
public class OrderCleanupBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public OrderCleanupBackgroundService(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using (var scope = _scopeFactory.CreateScope()) // a NEW, manually-created scope — one per iteration
{
var repository = scope.ServiceProvider.GetRequiredService<IOrderRepository>(); // scoped service, resolved SAFELY
await repository.CleanupExpiredOrdersAsync();
} // scope disposed here — the scoped repository (and anything it owns) is cleaned up
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
IServiceScopeFactory is itself always registered as a singleton by the framework (it's safe to inject directly into a singleton-lifetime BackgroundService, since creating scopes is exactly its job) — calling .CreateScope() produces a genuine, independent scope, with its own scoped service instances, entirely separate from any HTTP request's scope; wrapping it in using ensures it's disposed (and its scoped services cleaned up) once the iteration's work is done, exactly mirroring how the framework automatically disposes a request's scope at the end of that request (Section 5).
12. The Options Pattern: DI-Integrated Configuration
Binding configuration to a strongly-typed class, resolved through the same container
public class EmailOptions
{
public string ApiKey { get; set; } = "";
public string FromAddress { get; set; } = "";
}
builder.Services.Configure<EmailOptions>(builder.Configuration.GetSection("Email"));
The Options pattern integrates directly with the same DI container this whole guide covers — Configure<T> binds a section of configuration (appsettings.json, environment variables, and so on) to a strongly-typed class, and registers the mechanism to make it injectable, without you ever manually calling IConfiguration["Email:ApiKey"] scattered throughout your code.
Consuming options via IOptions<T>, IOptionsSnapshot<T>, or IOptionsMonitor<T>
public class SendGridEmailSender : IEmailSender
{
private readonly EmailOptions _options;
public SendGridEmailSender(IOptions<EmailOptions> options) => _options = options.Value; // SINGLETON-safe — resolved once
public SendGridEmailSender(IOptionsSnapshot<EmailOptions> options) => _options = options.Value; // SCOPED — re-read per scope
public SendGridEmailSender(IOptionsMonitor<EmailOptions> options) => // resolved as a SINGLETON, but reacts to LIVE changes
options.OnChange(newOptions => Console.WriteLine("Config changed!"));
}
This trio directly reflects Section 4's lifetime concepts, applied specifically to configuration: IOptions<T> is itself registered as a singleton and captures the configuration's value once (safe to inject into a singleton service, per Section 7's rule); IOptionsSnapshot<T> is scoped, re-reading the configuration fresh for each new scope (useful if configuration might change and a request should see a consistent, current snapshot); IOptionsMonitor<T> is a singleton that actively supports live change notification, appropriate for long-lived services that need to react to configuration changes without restarting.
13. Disposal: How the Container Cleans Up After Itself
The container tracks and disposes IDisposable services automatically, according to their lifetime
public class SqlOrderRepository : IOrderRepository, IDisposable
{
private readonly SqlConnection _connection = new(/* ... */);
public void Dispose() => _connection.Dispose(); // called AUTOMATICALLY by the container
}
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
This directly connects to this series' Memory Management guide's Section 6-9 discussion of IDisposable — the DI container, once it constructs an IDisposable service, takes on responsibility for calling Dispose() on it automatically, at the appropriate point in that service's lifetime: a scoped IDisposable is disposed when its owning scope ends (Section 5's end of a request, or Section 11's manually-created scope's using block); a singleton IDisposable is disposed when the application itself shuts down; a transient IDisposable is disposed when the scope that resolved it ends (since transients have no independent lifetime tracking of their own beyond that).
Why this matters: you almost never need to manually dispose a DI-resolved service yourself
public class OrdersController : ControllerBase
{
private readonly IOrderRepository _repository; // resolved by the container — DO NOT manually Dispose() this
public OrdersController(IOrderRepository repository) => _repository = repository;
// no Dispose() override needed here — the CONTAINER handles cleanup of _repository automatically
}
This is worth stating explicitly, since it's a common point of confusion for developers newly combining this series' Memory Management guide's IDisposable discipline with DI-resolved services: manually calling .Dispose() on a service the container gave you is both unnecessary and actively dangerous — the container will also try to dispose it at the appropriate time, and disposing an object twice can throw or behave unpredictably; disposal responsibility for container-resolved services belongs entirely to the container, not to whoever happens to be holding a reference to the resolved instance.
14. Common Pitfalls
| Pitfall | Why it hurts | Better approach |
|---|---|---|
| A singleton service depending on a scoped (or transient) service | The captive dependency problem — the shorter-lived service gets held far longer than intended, risking stale data or thread-safety violations | Never let a longer-lived service depend on a shorter-lived one (Section 7); use IServiceScopeFactory to create scopes on demand instead (Section 11) |
Assuming ValidateScopes protects Production the same way it protects Development by default |
Captive dependencies can slip into Production undetected if this validation isn't explicitly enabled there too | Consider explicitly enabling scope validation beyond just the Development environment for extra safety (Section 8) |
| Registering multiple implementations of an interface, expecting the container to pick "the right one" contextually | A single-instance resolution just returns the LAST registered implementation, which is easy to misunderstand as broken or arbitrary | Use IEnumerable<T> when you genuinely want every registered implementation; understand single-instance resolution returns only the last one (Section 9) |
Manually calling .Dispose() on a service resolved from the DI container |
The container will also attempt to dispose it at the appropriate time, risking a double-dispose | Let the container manage disposal of anything it resolved; never dispose a DI-provided instance yourself (Section 13) |
| Trying to use a scoped service from a background worker with no ambient request scope | There's no automatically-created scope outside the HTTP request pipeline for a background service to resolve scoped dependencies from | Inject IServiceScopeFactory and create an explicit scope per unit of work (Section 11) |
| Registering a genuinely stateful, expensive-to-construct service as Transient | Every single resolution constructs a brand-new instance, which can be wasteful for anything meant to be shared or expensive to build | Use Scoped or Singleton for anything genuinely meant to be shared or costly to construct; reserve Transient for lightweight, stateless services (Section 4) |
Expecting IOptions<T> to reflect configuration changes made after the application started |
IOptions<T> captures its value once, at first resolution, and never updates |
Use IOptionsSnapshot<T> (per-scope refresh) or IOptionsMonitor<T> (live change notification) if configuration genuinely needs to be re-read or reacted to (Section 12) |
Assuming an unregistered dependency fails silently or resolves to null
|
It throws a specific InvalidOperationException at resolution time, naming the missing service |
Treat this exception as a clear, actionable signal — check Program.cs for the missing registration (Section 6) |
Quick Reference Table
| Concept | C# Syntax | Purpose |
|---|---|---|
| Registering a service | builder.Services.AddScoped<IFoo, Foo>(); |
Tells the container how to construct IFoo and how long to keep instances alive |
| Transient lifetime | AddTransient<T>() |
A brand-new instance on every single resolution |
| Scoped lifetime | AddScoped<T>() |
One instance per scope — one per HTTP request, by default |
| Singleton lifetime | AddSingleton<T>() |
One instance for the entire application's lifetime |
| Consuming a service | Constructor parameter of the requesting class | The idiomatic, automatic way to receive dependencies |
| Multiple implementations | IEnumerable<INotificationChannel> |
Resolves every registered implementation of an interface at once |
| Custom construction logic | AddScoped<T>(sp => new T(...)) |
A factory delegate for services needing non-default construction |
| Manual scope creation | IServiceScopeFactory.CreateScope() |
Creates an independent scope outside the HTTP request pipeline (Section 11) |
| Strongly-typed configuration |
IOptions<T> / IOptionsSnapshot<T> / IOptionsMonitor<T>
|
Injects configuration values with lifetime semantics matching Transient/Scoped/Singleton needs |
| Automatic disposal | (no explicit syntax — automatic) | The container disposes IDisposable services at the end of their registered lifetime |
Conclusion
ASP.NET Core's built-in dependency injection container turns the general pattern this series' Interfaces guide introduces into a concrete, framework-integrated mechanism — one where getting the lifetime of a registration right matters just as much as getting the abstraction itself right. Transient, Scoped, and Singleton aren't interchangeable conveniences; each represents a genuinely different sharing and lifespan guarantee, and the captive dependency problem — a longer-lived service silently holding onto a shorter-lived one — is the single most consequential mistake this model makes possible, precisely because it can compile cleanly, run without any immediate error, and only reveal itself through confusing, hard-to-reproduce production symptoms (stale data, unexpected concurrency issues) unless scope validation catches it first.
Everything else this guide covers — factory registrations, multiple implementations, manually-created scopes for background work, the options pattern's lifetime-matched variants, and disposal handled entirely by the container — builds on the same three-lifetime foundation, and the general rule from Section 7 (never depend on something shorter-lived than yourself) is worth carrying as the one piece of guidance that resolves the overwhelming majority of real-world DI lifetime confusion in ASP.NET Core applications.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the stale-DbContext-served-wrong-data-under-load incident that made the captive dependency problem click far better than any lifetime diagram ever could.
Top comments (0)