C sharp - Dependency Injection Internals in .NET
Dependency Injection (DI) in .NET is a design pattern and framework feature that enables loose coupling between components by providing required dependencies from the outside rather than creating them internally. While most developers use DI through simple registration and constructor injection, understanding its internal working gives deeper control over performance, maintainability, and architecture.
1. Core Concept of Dependency Injection
At its core, DI separates the creation of an object from its usage. Instead of a class instantiating its dependencies using the new keyword, those dependencies are provided externally, typically by a container.
For example, if a service depends on a repository, the DI container is responsible for creating the repository and injecting it into the service. This allows easy replacement, testing, and scalability.
2. The IServiceCollection and Service Registration
In .NET, dependencies are registered in a collection called IServiceCollection. This is essentially a list of service descriptors, where each descriptor contains:
-
The service type (interface or base class)
-
The implementation type (concrete class)
-
The lifetime (Transient, Scoped, Singleton)
When you call methods like AddTransient, AddScoped, or AddSingleton, you are adding entries to this collection. Internally, each registration is stored as a ServiceDescriptor object.
3. Building the Service Provider
Once all services are registered, the framework builds a service provider using BuildServiceProvider(). This creates an object that implements IServiceProvider.
Internally, the service provider:
-
Processes all service descriptors
-
Builds a dependency graph
-
Prepares factories (functions) to create objects
-
Optimizes resolution using compiled expressions or delegates
This step is crucial because it transforms the registration data into an efficient runtime object creation system.
4. Service Resolution Process
When a dependency is requested, the DI container performs the following steps:
-
It checks if the requested service type is registered
-
It identifies the appropriate implementation
-
It examines the constructor of the implementation
-
It recursively resolves all constructor parameters
-
It creates the object and injects dependencies
This recursive resolution builds the entire object graph automatically.
For example, if a controller depends on a service, and the service depends on a repository, the container resolves everything in a chain.
5. Lifetimes and Object Management
Service lifetimes control how and when instances are created:
-
Transient: A new instance is created every time the service is requested
-
Scoped: A single instance is created per scope (commonly per web request)
-
Singleton: A single instance is created and reused throughout the application
Internally, the container maintains caches for Scoped and Singleton services. Transient services are not cached and are always newly constructed.
Scoped services are stored in a scope-specific dictionary, while singletons are stored in a root-level cache.
6. Constructor Injection Mechanics
The default DI container in .NET uses constructor injection. It selects the constructor with the most parameters (by default) and resolves each parameter.
If multiple constructors exist, the container chooses the one it can fully satisfy. If it cannot resolve a parameter, it throws an exception.
Internally, constructor metadata is analyzed using reflection during the service provider build phase, and then optimized for faster runtime resolution.
7. Expression Trees and Performance Optimization
To improve performance, the .NET DI container does not rely on reflection for every object creation. Instead, it builds expression trees or compiled delegates that represent object creation logic.
These compiled delegates are cached and reused, making dependency resolution much faster after the initial setup.
This is why DI in .NET is efficient even for large applications.
8. Scoped Containers and Request Pipelines
In web applications, a new scope is created for each HTTP request. This ensures that scoped services are unique per request and disposed of afterward.
Internally, the framework creates a child container (scope) from the root provider. This scoped provider maintains its own cache for scoped instances while still accessing singleton services from the root.
9. Disposal of Services
The DI container is responsible for disposing of services that implement IDisposable.
-
Transient services are disposed immediately after use (if created within a scope)
-
Scoped services are disposed when the scope ends
-
Singleton services are disposed when the application shuts down
The container tracks disposable objects and ensures proper cleanup to avoid memory leaks.
10. Limitations of the Built-in DI Container
While powerful, the default DI container in .NET has some limitations:
-
No built-in support for property injection
-
Limited support for named or keyed services
-
No advanced interception or aspect-oriented features
Because of this, some applications use third-party containers like Autofac or Ninject for more advanced scenarios.
11. Practical Example of Internal Flow
Consider a controller that depends on a service, which depends on a repository:
-
The framework receives a request
-
A scope is created
-
The controller is requested
-
The container resolves the service
-
The service triggers resolution of the repository
-
All objects are created and injected
-
The request completes, and scoped services are disposed
This entire process happens automatically and efficiently.
Conclusion
Understanding the internals of dependency injection in .NET reveals how the framework manages object creation, lifetime, and performance. It relies on service descriptors, compiled factories, scoped containers, and caching mechanisms to resolve dependencies efficiently. This knowledge helps developers design better architectures, avoid common pitfalls, and optimize application performance.