Zenject: How an IoC container can kill Dependency Injection on your project

    Where do the dangers begin from? Suppose you have firmly decided that you will develop a project, adhering to a specific concept or approach. In our situation, this is DI, although, for example, Reactive Programming may also be in its place. It is quite logical that in order to realize your goal, you will turn to ready-made solutions (in our example - the DI Zenject container). You will familiarize yourself with the documentation and start building an application framework using basic functionality. If at first the use of the solution you do not have any unpleasant feelings, then most likely it will linger on your project for the rest of its life. As you work with the basic functions of the solution (container), you may have questions or desires to make some functionality more beautiful or effective way. Surely First of all, you refer to the more advanced "features" of the solution (container). And at this stage the following situation may arise: you already know and trust the chosen solution quite well, which is why many may not think about how ideologically correct the use of one or another functionality in a solution may be, or the transition to another solution is already quite expensive and inappropriate ( for example, the deadline is approaching. It is at this stage that the most dangerous situation may arise - the solution functionality is applied with little caution, or in rare cases just on the machine (mindlessly). which is why many may not think about how ideologically correct the use of one or another functionality in a solution may be, or the transition to another solution is already quite expensive and impractical (for example, the deadline is approaching). It is at this stage that the most dangerous situation may arise - the solution functionality is applied with little caution, or in rare cases just on the machine (mindlessly). which is why many may not think about how ideologically correct the use of one or another functionality in a solution may be, or the transition to another solution is already quite expensive and impractical (for example, the deadline is approaching). It is at this stage that the most dangerous situation may arise - the solution functionality is applied with little caution, or in rare cases just on the machine (mindlessly).

    Who might be interested?


    This article will be useful both to those who are well acquainted with DI, as well as novice DI adepts. To understand enough basic knowledge of what patterns are used DI, the purpose of the DI and the functions that the IoC container performs. It's not about the intricacies of the Zenject implementation, but about the use of part of its functionality. The article is based only on the official Zenject documentation and code samples from it, as well as on the book by Mark Siman “Deploying Dependencies in .NET”, which is a classic exhaustive work on the DI theory. All quotes in this article are excerpts from the book by Mark Siman. Despite the fact that we will focus on a specific container, the article may be useful to those who use other containers.

    The purpose of this article is to show how a tool whose purpose is to help you implement DI on your project can steer you in a completely different direction, pushing you to make mistakes that bind your code, which reduces testability of the code, in general, deprive you of all the benefits that can give you DI.

    Disclaimer : The purpose of the article is not to criticize Zenject himself or his authors. Zenject can be used strictly for its intended purpose and serve as an excellent tool for implementing DI, provided that you will not use its full set of functions, defining for yourself some limitations.

    Introduction


    Zenject is an open source dependency injection container designed to be used with the Unity3D game engine, providing work on most platforms supported by Unity3D. It should be noted that Zenject can also be used for C # applications developed without Unity3D. This container is quite popular among Unity developers, actively supported and developed. In addition, Zenject has all the necessary container DI functionality.

    I used Zenject in 3 large Unity projects, and also communicated with a large number of developers using it. The reason for writing this article is frequently asked questions:

    • Is using Zenject a good solution?
    • What is wrong with Zenject?
    • What difficulties arise when using Zenject?

    As well as some projects in which the use of Zenject did not lead to the solution of problems of strong code connectivity and unsuccessful architecture, but on the contrary aggravated the situation.

    Let's see why developers have such questions and problems. You can reply as follows:
    Ironically, DI containers themselves tend to be stable dependencies. ... When you decide to develop your application based on a DI container, you risk being limited by this choice for the entire life cycle of the application.
    It is worth making a remark that with proper and limited use of the container, the transition to using another container in the application (or abandoning the use of the container in favor of “ implementation for the poor ”) is quite possible and does not take much time. However, in such a situation, it is unlikely that you will need this.

    Before you begin to understand the potentially dangerous functionality of Zenject, it makes sense to superficially refresh a few basic aspects of DI.

    The first aspect is the purpose of the DI containers. Mark Siman writes in his book on this subject the following:
    DI Container is a software library that can automate many tasks performed while assembling objects and managing their life cycle.
    Do not expect the DI container to magically turn strongly bound code into loosely coupled. The container can increase the efficiency of using DI, but the emphasis in the application should be made primarily on the use of patterns and work with DI.
    The second aspect is the DI patterns . Mark Siman outlines four basic patterns, sorted by frequency and the need to use them:

    1. Implementing a constructor - How can you ensure that the required dependency is always available to the developed class?
    2. Implementing a property — How can I enable DI as an option in a class if there is a suitable local default?
    3. Method implementation - How can dependencies be introduced into a class if they are different for each operation?
    4. Ambient Context - How can we make the dependency available in each module without including the end-to-end aspects of the application in each API component?

    The questions listed next to the name of the patterns fully describe their field of application. At the same time, the article will not discuss the Implementation of the Designer (as there are almost no complaints about its implementation in Zenject) and the Environmental Context (its implementation is not in the container, but you can easily implement it based on the existing functionality).
    Now you can go directly to the potentially dangerous functionality of Zenject.

    Dangerous functionality.


    Implementing Properties


    This is the second most common DI pattern, after the introduction of the constructor, but it is used much less frequently. Implemented in Zenject as follows:

    publicclassFoo
    {
        [Inject]
        public IBar Bar
        {
            get;
            privateset;
        }
    }

    In addition, in Zenject there is also such a thing as “Field Injection”. Let's see why in all Zenject this functionality is the most dangerous.

    • To show the container which field to embed, the attribute is used. This is an understandable solution, in terms of simplicity and logic of the implementation of the container itself. However, we see an attribute (as well as a namespace) in the class code. That is, at least indirectly, but the class begins to know about where it gets its dependency from. Plus, we are starting to strongly tie the class code to the container. In other words, we will not be able to refuse to use Zenject without manipulating the class code.
    • The pattern itself is used in a situation where the dependency has a local default. That is, this is an optional dependency, and if the container cannot provide it, then there will be no errors in the project, and everything will work. However, using Zenject, you always get this dependency - the dependency becomes optional.
    • Since the dependency is not optional in this case, it begins to spoil the entire logic of the constructor implementation, because only dependencies should be implemented there. By implementing non-optional dependencies through properties, you get the opportunity to create circular dependencies in your code. They will not be so obvious, because in Zenject first implements the implementation of the constructor, and then the implementation of the property, and you will not receive a warning from the container.
    • Using the DI container implies an implementation of the Composition Root pattern, however, using the attribute to set up property injection means that you configure the code not only in the Layout Root, but also as needed in each class.

    Factories (and MemoryPool)


    The Zenject documentation has a whole section dedicated to factories. This functionality is implemented at the level of the container itself, and it is also possible to create your custom factories. Let's take a look at the first example from the documentation:

    publicclassEnemy
    {
        DiContainer Container;
        publicEnemy(DiContainer container)
        {
            Container = container;
        }
        publicvoidUpdate()
        {
            ...
            var player = Container.Resolve<Player>();
            WalkTowards(player.Position);
            ...
            etc.
        }
    }

    Already in this example there is a gross violation of DI. But this is rather an example of how to make a fully custom factory. What is the main problem here?
    A DI container may be mistakenly considered a service locator, but it should only be used as a mechanism for building object graphs. If we consider the container from this point of view, it makes sense to limit its use only to the layout root. This approach has the important advantage that it excludes any binding between the container and the rest of the application code.
    Let's turn to how the built-in factories from Zenject work. To do this, there is an IFactory interface, the implementation of which leads us to the PlaceholderFactory class:

    publicabstractclassPlaceholderFactory<TValue> : IPlaceholderFactory
        {
            [Inject]
            voidConstruct(IProvider provider, InjectContext injectContext)

    In it, we see the InjectContext parameter with many constructors, of the form:

    publicInjectContext(DiContainer container, Type memberType)
                : this()
            {
                Container = container;
                MemberType = memberType;
            }

    And again, we get the transfer of the container itself as a dependency class. This approach is a gross violation of the DI and the partial transformation of the container into the Service Locator.
    In addition, the disadvantage of this solution is that the container is used to create short-term dependencies, and should only create long-term dependencies.

    To avoid such violations, the authors of the container could completely exclude the possibility of transferring the container as a dependency to all registered classes. Implementing this would be easy, given that the entire container works by means of reflection and analysis of the parameters of methods and constructors for creating and assembling an application object graph.

    Method Implementation


    The logic of the implementation of the Method implementation in Zenject is as follows: first, the constructor is implemented in all classes, then the implementation of properties, and finally the implementation of the method. Consider the implementation example provided in the documentation:

    publicclassFoo
    {
        [Inject]
        publicInit(IBar bar, Qux qux)
        {
            _bar = bar;
            _qux = qux;
        }
    }

    What are the disadvantages here:

    • You can write arbitrarily methods that will be implemented within a single class. Thus, as in the case of the implementation of the property, we are able to make as many cyclic dependencies as possible.
    • As well as the implementation of the property, the implementation of the method is implemented according to the attribute, which connects your code with the code of the container itself.
    • The implementation of the method in Zenject is used only as an alternative to constructors, which is convenient in the case of MonoBehaviour classes, but absolutely contradicts the theory described by Mark Siman. A classic example of a canonical method implementation is the use of factories (factory methods).
    • If there are several implemented methods in the class, or besides the method there is also a constructor, it turns out that the dependencies needed by the class will be scattered in different places, which will prevent us from perceiving the whole picture. That is, if class 1 has a constructor, then the number of its parameters can visually show whether there is a design error in the class, and whether the principle of sole responsibility is violated, and if the dependencies are scattered by several methods, the constructor, or maybe even a couple of properties, then the picture will not be as obvious as it could be.

    It follows that the presence of such an implementation of the implementation of the method in a container that contradicts the DI theory does not have a single plus. With a big reservation, a plus can only be considered the possibility of using the embedded method as a constructor for MonoBehaviour. But this is quite a controversial point, because from the point of view of container logic, DI patterns and Unity3D internal memory management, all MonoBehaviour objects of your application can be considered managed resources, and in this case, it will be much more efficient to delegate the life cycle management of such objects not to a DI container, but to an auxiliary class (be it a Wrapper, ViewModel, Fasade, or something else).

    Global bindings


    This is a fairly convenient auxiliary functionality that allows you to set global binders that can live independently of the transition between scenes. Details can be read in the documentation.. This functionality is extremely convenient and quite useful. It is worth noting that it does not violate the patterns and principles of DI, however, it has an unobvious and ugly implementation. The bottom line is that you create a special kind of prefab, attach a script to it with the container configuration (Installer) and save it in a strictly defined project folder, without the ability to move it somewhere and without any references to it. The disadvantage of this instrument lies solely in its implicitness. When it comes to ordinary installers, everything is quite simple: you have an object on the stage, an installer script is hanging on it. If a new developer comes to the project, then the installer becomes an excellent point to dive into the project. A developer can, on the basis of a single installer, make an idea of What modules does the project consist of and how is the object graph constructed? But with the use of global binding, the installer on the scene ceases to be a sufficient source of this information. There are no references to global binding in the code of other installers (present on the scenes) and therefore, you do not see the full object graph. And only in the course of the analysis of classes, you understand that the part of the binding is not enough in the insteller on the stage. Once again, make a reservation that this flaw is purely cosmetic. that part of the binding is missing in the insteller on stage. Once again, make a reservation that this flaw is purely cosmetic. that part of the binding is missing in the insteller on stage. Once again, make a reservation that this flaw is purely cosmetic.

    Identifiers


    The ability to specify a specific binding identifier in order to get a certain dependence in the class from the set of similar dependencies. Example:

    Container.Bind<IFoo>().WithId("foo").To<Foo1>().AsSingle();
    Container.Bind<IFoo>().To<Foo2>().AsSingle();
    publicclassBar1
    {
        [Inject(Id = "foo")]
        IFoo _foo;
    }
    publicclassBar2
    {
        [Inject]
        IFoo _foo;
    }

    This functionality can actually be situationally useful, and comes as an additional option for the implementation of properties. However, along with the convenience, it inherits all the problems identified in the “Implementing Properties” section, adding even greater code connectivity by the means of introducing some constant that must be remembered when configuring your code. By accidentally deleting this identifier, you can easily get a non-working from the working application.

    Signals and ITickable


    Signals are an analogue of the “Event Aggregator” mechanism built into the container. The idea of ​​implementing this functionality is undoubtedly noble, as it aims to reduce the number of connections between objects that communicate by means of the subscription-event mechanism. A rather voluminous example can be viewed in the documentation , however, it will not be in the article, because the concrete implementation does not matter.

    ITickable interface support - replacing standard Update, LateUpdate and FixedUpdate methods in Unity by delegating calls to update objects with the ITickable interface to the container. An example is also in the documentation , and its implementation in the context of the article also does not matter.

    The problem of Signals and ITickable does not concern the aspects of their implementation, its root lies in using the side effects of container operation. At its core, the container knows almost all classes and their instances within the project, but its responsibility is to create a graph of objects and manage their life cycle. Adding mechanisms by the type of Signals, ITickable, and so on, we add more and more responsibility to the container, and more and more we attach application code to it, making it an exclusive and irreplaceable part of the code, practically “Divine Object”.

    Instead of output


    The most important thing about containers is to understand that the use of DI does not depend on the use of a DI container. An application can be built from many loosely coupled classes and modules, and none of these modules need to know anything about the container.
    Be vigilant when using ready-made (boxed) solutions or small plug-ins. Use them thoughtfully. After all, similar theoretical mistakes and blots can sin even more ambitious things that you rely on (for example, the game engines of the scale of Unity3D itself). And this, ultimately, will not affect the work of the solution you use, but the sustainability, performance and quality of your final product. I hope everyone who read to the end, the article will be useful or, at least, will not be sorry for the time spent on reading it.

    Also popular now: