VOOZH about

URL: https://dev.to/libintombaby/how-dependency-injection-works-internally-in-net-30pe

⇱ How Dependency Injection Works Internally in .NET - DEV Community


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:

  1. Looks up the registered ServiceDescriptor
  2. Checks if an existing instance should be returned (singleton/scoped)
  3. If not, creates a new instance using the registered constructor
  4. Resolves all constructor dependencies recursively
  5. 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:

  1. Finds OrderService as the implementation
  2. Inspects its constructor via reflection
  3. Resolves IDbContext, IEmailService, and ILogger<OrderService> recursively
  4. Creates the OrderService instance 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

  • IServiceCollection is just a list of descriptors — nothing is created at registration
  • IServiceProvider is 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 = true catches 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."