This blog post will show you a straightforward Dependency Injection container.
This will give a better understanding of what Dependency Injection is and how it is done. And sure, we will see how this is related to IoC - Inversion of Control.
Dependency Injection and Inversion of Control
Dependency injection and inversion of control are related software design patterns that are often used together. Both patterns involve separating an object's behavior from its dependencies, but they do so in slightly different ways.
Inversion of control is a design principle in which the control flow in a program is inverted compared to traditional control flow. In conventional control flow, the program determines which code to execute and in what order. With inversion of control, this control is handed over to another entity, such as a framework or container, which determines the order in which the program's code is executed. This can make the program more flexible and modular since the code is not tightly coupled to a specific control flow.
Dependency injection is a specific implementation of inversion of control in which an object's dependencies are provided to that object from the outside. Instead of an object creating or looking up its dependencies, they are given to it as constructor parameters or setter methods. This makes the object's dependencies more explicit and allows them to be easily swapped out or mocked for testing.
In summary, inversion of control is a design principle that involves handing over control to another entity. At the same time, dependency injection is a specific way of implementing inversion of control by providing an object with its dependencies from the outside.
The simplest approach
So what do we want from such a tool? Often times our objects are held inside a container. Like a real container in the real world. You put stuff in and you can only get stuff out which was somehow in that container. The usage would look something around this
// Register the interface with a given type
Container.Register<ICalculator, Calculator>();
// Get it back from the container
var instance = Container.Resolve<ICalculator>();
var sum = instance.Add(1, 2);
As described earlier you can separate your concerns easily. You have someone responsible for wiring something up. And this one is not the same as the one having the logic. So let's take our example from a second ago. We have an interface and a class like this:
public interface ICalculator
{
int Add(int a, int b);
}
public class Calculator : ICalculator
{
public int Add(int a, int b) => a + b;
}
Nothing fancy, but enough to get our first version going. Let's write a simple DI-Container.
public class Container
{
private readonly Dictionary<Type, Type> _registeredTypes;
public Container()
{
_registeredTypes = new Dictionary<Type, Type>();
}
public void Register<TInterface, TImplementation>() where TImplementation : TInterface
{
_registeredTypes[typeof(TInterface)] = typeof(TImplementation);
}
public TInterface Resolve<TInterface>()
{
var type = typeof(TInterface);
if (_registeredTypes.ContainsKey(type))
{
var implementationType = _registeredTypes[type];
return (TInterface)Activator.CreateInstance(implementationType);
}
throw new Exception($"The type {type.FullName} has not been registered");
}
}
The basic idea behind our first version is very simple. Every time when someone registers a type with an interface, we hold that mapping information in a dictionary. When a consumer now asks for a given type, we can look up in our internal dictionary to get the mapped type and we will create this type via reflection (Activator.CreateInstance
). If we use this code now in action, we will get a working example:
var container = new Container();
container.Register<ICalculator, Calculator>();
var calculator = container.Resolve<ICalculator>();
Console.WriteLine(calculator.Add(2, 3));
This will print out 5 to the console. Huray! Our first success and it was really easy, wasn't it? Now that thing is not really helpful. The power of DI comes into play once you have objects which depend on each other. So imagine you have a second service, which needs our ICalculator
:
public interface IMultiplier
{
int Multiply(int a, int b);
}
public class Multiplier : IMultiplier
{
private readonly ICalculator _calculator;
public Multiplier(ICalculator calculator)
{
_calculator = calculator;
}
public int Multiply(int a, int b)
{
if (a == 0 || b == 0)
return 0;
var result = 0;
for (var i = 0; i < Math.Abs(b); i++)
result = _calculator.Add(result, a);
return Math.Sign(b) == 1 ? result : -result;
}
}
Basically, we do multiplication by addition. The example is not so important. More important is the fact that we now have a dependency that can't be resolved by our container. So even if we register the new type we will get a runtime exception:
var container = new Container();
container.Register<ICalculator, Calculator>();
container.Register<IMultiplier, Multiplier>();
var calculator = container.Resolve<IMultiplier>();
Console.WriteLine(calculator.Multiply(2, 3));
Of course, Activator.CreateInstance
can not create a parameterless constructor of type Multiplier
. So let's extend our container so it can handle that situation with ease:
public class Container
{
private readonly Dictionary<Type, Type> _registeredTypes;
public Container()
{
_registeredTypes = new Dictionary<Type, Type>();
}
public void Register<TInterface, TImplementation>() where TImplementation : TInterface
{
_registeredTypes[typeof(TInterface)] = typeof(TImplementation);
}
public TInterface Resolve<TInterface>()
{
return (TInterface)Resolve(typeof(TInterface));
}
private object Resolve(Type type)
{
if (_registeredTypes.ContainsKey(type))
{
var implementationType = _registeredTypes[type];
var constructor = implementationType.GetConstructors().First();
var constructorParameters = constructor.GetParameters();
if (constructorParameters.Length == 0)
{
// If the constructor has no parameters, we can just create an instance
return Activator.CreateInstance(implementationType);
}
// If the constructor has parameters, we need to resolve them and pass them to the constructor
var parameterInstances = GetConstructorParameters(constructorParameters);
return Activator.CreateInstance(implementationType, parameterInstances.ToArray());
}
throw new Exception($"The type {type.FullName} has not been registered");
}
private List<object> GetConstructorParameters(ParameterInfo[] constructorParameters)
{
var parameterInstances = new List<object>();
foreach (var parameter in constructorParameters)
{
var parameterType = parameter.ParameterType;
var parameterInstance = Resolve(parameterType);
parameterInstances.Add(parameterInstance);
}
return parameterInstances;
}
}
Now we have a bit more code going on. The core concept is still the same, we have a dictionary that holds the mapping information. But this time. we check whether or not we have constructor parameters we have to provide. If so we just go through our container and collect all the types for that (recursively). So even if we have yet another dependency, which has other dependencies we would resolve them. You can imagine that like a tree we go through. We want to resolve all of the dependencies. Of course, at some point in the chain, there has to be an object, which does not have any dependencies. Also, this implementation would fail if we have a circle. Then we would run indefinitely. But this is just a simple implementation, which illustrates how DI works in practice.
The usage is super trivial and now everything works as it should:
Console.WriteLine(calculator.Multiply(2, 3));
Will now display "6" as it should.
Singleton scope
Until now we build a solid foundation on which we can build more features. The last thing I want to show you, to go a bit more into detail, is how we can handle different lifetime scopes. The DI framework allows you to register different types of objects with the framework, and then the framework will automatically create and manage the lifetime of these objects for you. The two most common lifetime scopes that you can use in ASP.NET are "singleton" and "transient".
The AddSingleton
method registers an object with the DI framework as a singleton. This means that the framework will create a single instance of the object and will use that same instance every time the object is requested by your application. The object will be created the first time it is requested, and it will be destroyed when the application is shut down.
The AddTransient
method registers an object with the DI framework as a transient. This means that the framework will create a new instance of the object every time it is requested by your application. The object will be created when it is first requested, and it will be destroyed when the code execution leaves the block in which it was created. This allows you to have more control over the lifetime of the object, but it also means that you will need to manage its creation and destruction manually.
Both of these lifetime scopes have their own advantages and disadvantages, and which one you use will depend on your specific requirements and the needs of your application. In general, singleton objects are better suited for objects that are expensive to create or maintain, while transient objects are better suited for objects that are lightweight and have a short lifetime.
So let us add the singleton version. As always the code used in that blog post is linked at the end and is available on my GitHub repository. So take your time.
First, we have to add a new dictionary to our container. The sole purpose is to have the information on which types are registered as singletons and also later on to retrieve those instances.
private readonly Dictionary<Type, object> _singletons = new();
public void RegisterSingleton<TInterface, TImplementation>() where TImplementation : TInterface
{
Register<TInterface, TImplementation>();
// add the type as singleton
_singletons[typeof(TInterface)] = null;
}
We are not doing anything special. We are still registering the type as usual but also we are setting a marker on our newly created dictionary, so we can check in the resolve method whether or not the user expects a singleton or not. The rest happens in our Resolve
method, where we have to check 1. is the type a singleton and if so do we already have an instance? If yes, return that instance and if not just continue the normal flow. And 2. once we created said instance, add this to the singleton dictionary if feasible.
private object Resolve(Type type)
{
if (_registeredTypes.ContainsKey(type))
{
// Check if we already have a singleton registered and we created an instance
if (_singletons.TryGetValue(type, out var value) && value is not null)
return _singletons[type];
var implementationType = _registeredTypes[type];
var constructor = implementationType.GetConstructors().First();
var constructorParameters = constructor.GetParameters();
object? instance = null;
if (constructorParameters.Length == 0)
{
// If the constructor has no parameters, we can just create an instance
instance = Activator.CreateInstance(implementationType);
}
else
{
var parameterInstances = GetConstructorParameters(constructorParameters);
instance = Activator.CreateInstance(implementationType, parameterInstances.ToArray());
}
// If we are a singleton, add this to the dictionary
TryAddWhenSingleton(type, instance);
return instance;
}
throw new Exception($"The type {type.FullName} has not been registered");
}
private void TryAddWhenSingleton(Type type, object instance)
{
if (_singletons.ContainsKey(type))
_singletons[type] = instance;
}
If we run now the following code:
container.RegisterSingleton<ICalculator, Calculator>();
container.Register<IMultiplier, Multiplier>();
Console.WriteLine($"First call to GetHashCode in ICalculator: {container.Resolve<ICalculator>().GetHashCode()}");
Console.WriteLine($"Second call to GetHashCode in ICalculator: {container.Resolve<ICalculator>().GetHashCode()}");
Console.WriteLine($"First call to GetHashCode in IMultiplier: {container.Resolve<IMultiplier>().GetHashCode()}");
Console.WriteLine($"Second call to GetHashCode in IMultiplier: {container.Resolve<IMultiplier>().GetHashCode()}");
We can see that the hash code for ICalculator
is two times the same, indicating that we have the same instance. The call to IMultiplier
retrieves always two different hash codes, indicating that these are two different instances.
Conclusion
There you got it. A simple and also naive implementation of a dependency injection container. I hope you now have a better understanding of the inners of such a container. Of course, a real-world container has to cover way more concerns. Imagine one of your types implements IDisposable
, how do we model that? Maybe a small homework for you 😉.