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:
-
It checks if the requested type is registered.
-
It determines the implementation type.
-
It looks at the constructor of the implementation.
-
It recursively resolves all constructor parameters.
-
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:
-
Identify the constructor (usually the one with the most parameters).
-
Determine parameter types.
-
Resolve each parameter recursively.
-
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:
-
Store mappings in a dictionary
-
Use reflection to find constructors
-
Recursively resolve dependencies
-
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.