C sharp - Dependency Injection Internals in .NET (C#)

Dependency Injection (DI) is a design pattern used to achieve loose coupling between classes by providing their dependencies from the outside rather than creating them internally. In modern C# applications, especially those built with ASP.NET Core, DI is a built-in feature. To understand it deeply, it is important to explore how it works internally.


1. What Happens Internally in Dependency Injection

At its core, a DI system maintains a service container (also called an IoC container). This container is responsible for:

  • Storing information about services (types and their implementations)

  • Creating instances of those services when requested

  • Managing the lifetime of each service

When you register services, you are essentially telling the container:

  • What interface or base type to look for

  • What concrete class should be created

  • How long the instance should live

Example:

services.AddScoped<IMyService, MyService>();

Internally, this registration is stored as a mapping:

  • Service Type: IMyService

  • Implementation Type: MyService

  • Lifetime: Scoped


2. Service Descriptor and Service Collection

The DI system uses a structure called a ServiceDescriptor to represent each registered service. These descriptors are stored in a ServiceCollection, which is simply a list of all service registrations.

Each descriptor contains:

  • Service type (e.g., IMyService)

  • Implementation type or factory

  • Lifetime (Transient, Scoped, Singleton)

When the application starts, the ServiceCollection is converted into a ServiceProvider, which is the actual engine that resolves dependencies.


3. Service Provider and Resolution Process

The ServiceProvider is responsible for creating objects when requested.

When a class requests a dependency (for example, through constructor injection), the provider performs the following steps:

  1. It checks if the requested type is registered.

  2. It determines the implementation type.

  3. It looks at the constructor of the implementation.

  4. It recursively resolves all constructor parameters.

  5. It creates the object and returns it.

Example:

public class OrderService
{
    private readonly IMyService _service;

    public OrderService(IMyService service)
    {
        _service = service;
    }
}

Resolution flow:

  • OrderService is requested

  • DI sees it needs IMyService

  • DI creates MyService instance

  • Injects it into OrderService


4. Lifetimes and How They Work Internally

There are three main lifetimes:

Transient

  • A new instance is created every time it is requested.

  • No caching is involved.

Scoped

  • One instance is created per scope (e.g., per HTTP request).

  • Internally, a scope dictionary stores created instances.

Singleton

  • Only one instance is created for the entire application.

  • Stored in a global cache inside the container.

The ServiceProvider maintains internal storage structures to track these instances and reuse them when needed.


5. Constructor Injection Mechanism

The DI container uses reflection to inspect constructors.

Steps:

  1. Identify the constructor (usually the one with the most parameters).

  2. Determine parameter types.

  3. Resolve each parameter recursively.

  4. Invoke the constructor using reflection.

This process is optimized in .NET by compiling expression trees into delegates to avoid repeated reflection overhead.


6. Factory Methods and Lazy Instantiation

Instead of directly specifying a class, you can register services using a factory method:

services.AddSingleton<IMyService>(provider =>
{
    return new MyService("custom value");
});

Internally:

  • The container stores the factory delegate

  • When needed, it executes the delegate

  • The result is cached if it is a singleton

This allows dynamic creation logic.


7. Open Generics Support

The DI container supports generic types:

services.AddTransient(typeof(IRepository<>), typeof(Repository<>));

Internally:

  • The container recognizes open generic definitions

  • When a specific type like IRepository is requested, it constructs Repository dynamically


8. Circular Dependency Detection

If two services depend on each other, the container detects it during resolution.

Example:

  • A depends on B

  • B depends on A

Internally:

  • The container keeps track of the resolution chain

  • If a loop is detected, it throws an exception


9. Building a Simple Custom DI Container (Conceptual)

A minimal DI container would:

  1. Store mappings in a dictionary

  2. Use reflection to find constructors

  3. Recursively resolve dependencies

  4. Cache instances based on lifetime

Example (simplified):

public object Resolve(Type type)
{
    var constructor = type.GetConstructors().First();
    var parameters = constructor.GetParameters();

    var dependencies = parameters
        .Select(p => Resolve(p.ParameterType))
        .ToArray();

    return Activator.CreateInstance(type, dependencies);
}

This is a simplified version of what the real DI container does with many optimizations.


10. Performance Considerations

The built-in DI container in .NET is optimized for performance:

  • Uses compiled delegates instead of reflection after the first call

  • Minimizes allocations

  • Efficient caching strategies for scoped and singleton services

However, for very complex scenarios, third-party containers may offer additional features like interception or property injection.


Conclusion

Dependency Injection in .NET is more than just a convenience feature. Internally, it is a well-structured system that manages object creation, lifetime, and dependency resolution using service descriptors, a service provider, and optimized instantiation techniques. Understanding these internals helps developers design better architectures, debug issues more effectively, and even build custom frameworks when needed.