DEV Community

Cover image for How Dependency Injection Works Internally in .NET
Libin Tom Baby
Libin Tom Baby

Posted on

How Dependency Injection Works Internally in .NET

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>();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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."

Top comments (0)