ServiceCollection, IServiceProvider, resolution chain, scope factory, how the container builds the object graph
Every ASP.NET Core application uses dependency injection.
But most developers only know how to use it — not how the container actually works under the hood.
This guide explains the mechanics: how services are registered, how the container resolves them, how lifetimes are enforced, and the mistakes that silently break things.
The Three Building Blocks
1. IServiceCollection — the registration
IServiceCollection is just a list. When you call AddScoped<T>(), you're adding a ServiceDescriptor to that list.
builder.Services.AddSingleton<ILogger, Logger>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddTransient<IEmailService, EmailService>();
Nothing is created yet. You're just declaring what to build.
2. IServiceProvider — the container
When .Build() is called, the IServiceCollection is compiled into an IServiceProvider — the actual DI container.
var app = builder.Build(); // IServiceProvider is created here
3. Resolution — how objects are constructed
When a service is requested, the container:
- Looks up the registered
ServiceDescriptor - Checks if an existing instance should be returned (singleton/scoped)
- If not, creates a new instance using the registered constructor
- Resolves all constructor dependencies recursively
- Returns the fully-constructed object
The Three Lifetimes — Explained Internally
Singleton
builder.Services.AddSingleton<IConfigService, ConfigService>();
One instance created on first request. Stored in the root container. Returned for every subsequent request — forever.
Use for: stateless services, configuration, caches, HTTP clients.
Scoped
builder.Services.AddScoped<IDbContext, AppDbContext>();
One instance per HTTP request. Created when the scope starts, disposed when it ends.
In ASP.NET Core, a new IServiceScope is created for each HTTP request. All scoped services within that request share the same instance.
Use for: database contexts, unit-of-work, per-request state.
Transient
builder.Services.AddTransient<IEmailService, EmailService>();
A new instance every single time the service is requested. No sharing.
Use for: lightweight, stateless services where a fresh instance is always needed.
How Constructor Injection Works
The container uses reflection to inspect the constructor.
public class OrderService
{
private readonly IDbContext _db;
private readonly IEmailService _email;
private readonly ILogger<OrderService> _logger;
public OrderService(
IDbContext db,
IEmailService email,
ILogger<OrderService> logger)
{
_db = db;
_email = email;
_logger = logger;
}
}
When IOrderService is requested, the container:
- Finds
OrderServiceas the implementation - Inspects its constructor via reflection
- Resolves
IDbContext,IEmailService, andILogger<OrderService>recursively - Creates the
OrderServiceinstance with all dependencies injected
If any dependency is not registered, an InvalidOperationException is thrown at resolution time.
The Captive Dependency Problem
The most dangerous lifetime mistake.
A singleton depending on a scoped service:
// ❌ Captive dependency — runtime exception or silent bug
public class ReportService // Singleton
{
public ReportService(IDbContext db) { } // IDbContext is Scoped
}
A singleton lives forever. A scoped service is tied to a request. The scoped service is never released — it becomes effectively a singleton with incorrect state.
ASP.NET Core detects this in Development mode. Enable it explicitly in Production:
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});
Resolving Services Manually
// From IServiceProvider directly
var service = app.Services.GetRequiredService<IMyService>();
// Creating a scope manually (e.g. in a background service)
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.SaveChangesAsync();
Never resolve scoped services from the root IServiceProvider — they won't be disposed correctly. Always create a scope first.
Interview-Ready Summary
-
IServiceCollectionis just a list of descriptors — nothing is created at registration -
IServiceProvideris the compiled container that resolves and creates objects - Resolution works by inspecting constructors via reflection and recursively resolving dependencies
- Singleton = one instance forever; Scoped = one per request; Transient = always new
- Captive dependency = singleton holding a scoped service — causes bugs or exceptions
- Always use
CreateScope()when resolving scoped services in background contexts -
ValidateOnBuild = truecatches registration errors at startup, not at runtime
A strong interview answer:
"The .NET DI container is built from IServiceCollection — a list of service registrations. At build time, it compiles into an IServiceProvider. When a service is requested, the container uses reflection to inspect the constructor, recursively resolves each dependency, respects lifetimes, and returns the fully constructed object. The main lifetime trap is the captive dependency — a singleton holding a scoped service, which prevents proper disposal."
Top comments (0)