Dependency Injection

Inversion of control and Dependency Injection (DI) can be complex and span across many different topics dealing with code design and structure. This article will summarise how at REWIND, DI was used to decouple areas of code that by necessity need to communicate with one another, decreasing complexity and improving maintainability.

Renato Kuurstra
XRLO — eXtended Reality Lowdown
6 min readJul 1, 2020

--

REWIND

To keep this article short, a basic knowledge of the topic is required. There are plenty of articles covering the basics, but very few show real applications of the principle using a full-blown DI library like Zenject.

Inversion of control

There are several ways to achieve this, amongst others: service pattern, ECS, event bus, and dependency injection. The last is explored in this article. As code samples go, we are going to use Zenject and Unity 2019.3.

As you can see in the next image, the main difference between Inversion of Control (IoC) and normal flow resides in the usage of the IoC container.

Inversion of control container

When we use dependency injection, we need a container that can hold a reference of every dependency. For inversion of Control (IoC), usually, this container is simply a dictionary with a type used as a key, and the reference to the object as a value. To keep track of the dependency, we are going to bind every type of object we might require to actions needed to spawn/use/reuse this object.

Context Root

A context root is a special place in the code. This is where all the dependencies are set up and objects are created. In Zenject these are the various installer and factory scripts. This code is usually executed at the very beginning of the scene, possibly before/during awake functions. It’s the only place in the code where we access the IoC container, and thus is considered part of the context root.

In Zenject, an installer is the simplest example of the context root. The most important function in this place is the bind function used on the container. Let’s see an example:

This lets the container know that when an object needs a IMyInterface type object, that it will be taken from the concrete type MyConcreteClass. In this case, we also specify an instance (from the reference variable myConcreteObject). Normally, if you don't specify anything, a new object will be created every time you inject it!

By using FromInstance we make sure the object referenced is always the one we specified. If you have simple objects that need dependencies, but no object depends on them, then you want to simple inject them.

The context root is used when the application starts (in the case of Unity Engine when the scene is loaded). At runtime, to access the IoC container (also called the DI container) we need to use factories, which are created at load time and then injected where they are needed at runtime.

Injection

The act of injecting an object with dependencies is quite simple. There are multiple ways to achieve this:

  • Using the constructor: This is the preferred way — when constructing an object, we will call the constructor with parameters, and those will be the dependencies. The constructor will be called in factories, because factories are part of the context root we can hold a reference to the container and use it to resolve any dependency. The objects can be constructed inside the context root and then bound to the container.
  • Method injection: Monobehaviours in Unity can’t specify a constructor, that is reserved by Unity. In this case, we are going to have a method marked with the [Inject] attribute. This is the second-best method(after the constructor injection). Internally, we allow only one method to be marked for Injection per object and don't allow any logic to be added to the method.
  • Parameter & property: By adding the [Inject] attribute we can directly inject parameters and property. However, internally we actually avoid doing this because it makes dependencies obscure as the project grows in complexity.

Let’s see a concrete, non-trivial, example:

In our MyConcreteClass we are going to be injected with an object that implements the ILogger interface, and binds to the container with the id MyLoggerId. This allows for multiple objects of the same type, and passes them around using a defined literal, in this case, "MyLoggerId".

The other object this system needs is a factory. A factory is part of the context root, so it could be used to spawn objects solving dependencies at runtime. The inside usage of the factory is beyond the scope of this brief blog post, but Zenject documentation has some decent examples.

Another important note is the use by MyConcreteClass of the IInitializable interface. This will need a Initialize() method declared, which will be called AFTER the dependency injection has run. So the factory is guaranteed to be there at this point and we can spawn our object.

Interface Usage

In general, it is always a good idea to bind objects using interfaces. This will make every dependency an interface, which in turn will allow us to easily change the concrete object used inside an implementation. Zenject discourages this in its documentation, but forcing its usage allows us to easily implement and use a Test Driven Development cycle. A single object can be bound on multiple interfaces, to allow only certain systems to access certain functions.

Conclusions

Using dependency injection is a great way to decouple systems from concrete implementations. Enforcing the use of interfaces allows easy unit testing for every object, being able to mock objects on the fly.

A drawback to using dependency injection is the complexity of the tool used, in our case, Zenject, which we have found over time tends to address too many problems at once. We circumvent this issue by constraining the developer’s usage to the most basic functionality. For example, we only allow developers to bind interfaces of concrete objects and we don’t allow complex usage of factories to spawn objects. The syntax chosen is always the most obvious one and we avoid using anything more than the basic container functionality.

Main advantages to do so:

  • Code will be more consistent across multiple coders.
  • Less knowledge about the specific tool used is needed( Zenject ), more about the generic concept (DI).
  • Ability to opt-out of Zenject in the future if we want other solutions.

There are many good articles about DI out there — some of them greatly helped us in our choices — so we encourage you to look at them too:

XRLO: eXtended Reality Lowdown is brought to you by Magnopus, an immersive design and innovation company. If you want to talk tech, ideas, and the future, get in touch here.

Your claps and follows help us understand what our readers like. If you liked our articles, show them some love! ❤️

We’d also love to hear from you. If you’re passionate about all things XR, you can apply to contribute to XRLO here. ✍️

--

--