DI in iOS: Complete guide

Vitaly Batrakov
IT’s Tinkoff
Published in
19 min readMar 14, 2021

Hi everyone, my name is Vitaly, and I am an iOS developer in the Messenger unit in Tinkoff. Today we will talk about what Dependency Injection is, why do we use it, consider well-known DI libraries and compare them with each other.

Contents

Part 1. Introduction to DI
Part 2. Service Locator
Part 3. DI Libraries
Part 4. Battle of containers
Conclusion

Part 1. Introduction to DI

Dependency Injection is an object setup pattern where the dependencies of an object are specified externally, instead of being created by the object itself. In other words, objects are set up by external objects.

There are several ways to inject dependencies.

  1. Interface injection

Dependency is set through the interface implementation method:

2. Property injection

Dependency is set through the public property:

3. Method injection

Dependency is set through the public method:

4. Constructor injection

Dependency is set through the constructor/initializer:

Constructor injection is generally preferred since it does not have the disadvantages of the three previous methods:

  1. Interface Injection requires the creation of an additional protocol for injection into each object, which seems redundant.
  2. Property Injection allows you to create an object and start using it even before you have injected all the necessary dependencies, which can lead to errors due to the inconsistent state of the object. Also, we have to make the embedded fields public, which makes it possible to change them from the outside at any time, breaking encapsulation.
  3. Method injection is very similar to Property Injection, but injection occurs through the method. It is used less often than Property Injection, but has the same disadvantages.

But even Constructor injection can’t always be used. For example, when there are circular dependencies. Sometimes it is not very convenient to use Constructor injection. For example, when dependencies are created after the creation of the main object. You can use Constructor injection and make the dependency optional and pass it in as nil by default. But in such cases, Property injection is usually used.

Some developers think that Constructor injection is not always a good choice because it leads to a boilerplate code in an initializer that grows with the number of dependencies, but IMHO it’s not bad. Such initializers are an excellent indicator that you are placing too many dependencies in the object, and it may be worth remembering about the Single Responsibility Principle and thinking about better object decomposition.

What is the profit from using DI?

1) DI makes the dependencies of an object explicit (clearly declared in the object’s interface), which allows better control over the complexity of an object;

2) DI makes object dependencies external (passed to the object from the outside, not created internally), which allows you to separate the object creation code from the business logic, improving separation of concerns;

3) DI provides the ability to make dependencies flexible. You can replace an object with another, for example, if you close it with a protocol, hiding the implementation. Class code now only depends on interfaces, not concrete classes, hiding implementation details and reducing code coupling;

As a consequence of points 1) 2) and 3) — objects become easily testable.

4) Reuse of objects is simplified;

5) Improves decomposition due to the move of creational logic outside;

What are the disadvantages of this approach?

1) Increases the number of entities in the project. It leads to the creation of many additional classes and protocols;

2) Increases time to write code.

Part 2. Service Locator

Many articles about DI in iOS projects often also describe the Service Locator (SL) — a pattern, essence of which is the presence of a registry object, which objects access to obtain dependencies. The registry object knows how to get any dependencies it might need.

In the articles and the iOS community as a whole, there is some ambiguity in the interpretation of the concepts of ServiceLocator and DI.

The key difference between SL and DI patterns is that the locator is explicitly accessed inside the class, and the necessary dependencies are explicitly retrieved from it. With DI, on the contrary, dependencies are injected from the outside.

Let’s take a look at a simple example of a service locator:

As you can see, the implementation is quite simple. Inside there is a dictionary, where the key is a string containing the type name, and the value is the object that we registered in the locator. To get some kind of dependency, you need to register it first. Dependencies are usually registered in one place at the start of the application. This implementation is not thread-safe, but this is quite enough to demonstrate the essence of the pattern.

Also, we have implemented SL as a singleton, which is not strictly necessary.

Let’s take a look at an example of using SL.

For example, before SL you used to create dependencies right at the point of use:

func someMethod() {     let service: TestServiceProtocol = TestService()     // use service}

With SL, you will register dependencies somewhere at one point at application start and later retrieve them through the locator:

Pros and cons of the Service Locator

Pros:

  1. The ability to get any required dependency, hiding from the user the details of creating the dependency object;
  2. Eliminates the need to use singleton services;
  3. The convenience of testing — we can replace dependencies for mocks when registering.

Cons:

  1. It is a god-object that knows too much and has access to any object, which means that everyone who has access to it gets the same capabilities. Combined with the singleton problems, this problem gets worse.
  2. Promotes the creation of internal implicit dependencies, which leads to implicit coupling, which leads to implicit complexity;
  3. Errors in runtime: if you resolve a dependency that you did not register, you will only find out about on the error in runtime;
  4. It is often a singleton. Singletons themselves have many flaws and are often considered as anti-patterns;

Singleton problems:

  • global state and its implicit change;
  • implicit dependence on the singleton, which leads to implicit SRP violation;
  • tough dependence on the singleton, which makes testing hard;

As I mentioned above, SL does not necessarily has to be a singleton. We can, for example, inject it into objects via Constructor Injection:

But in that case the dependencies of our class still will be implicit. In this example, you can notice another drawback of SL — the need to use implicitly unwrapped optional, or check the optional for nil on each resolution. The fact is that we never know whether we have registered the service we need in the locator or not until we try to resolve it. We won’t get any bugs or build errors if a service hasn’t been registered. Just catch a crash at runtime.

To improve the last example a little, you can move the SL to a factory:

With that small improvement we implement the principle of least privilege and save objects from the need to know about SL (from knowing too much), leaving it only in the scope of factories. But that doesn’t eliminate the need to pass the SL object between factories. Someone will have to store this object because it will be needed in every factory of every application module. A good solution for such storage is the coordinator. Using DI (Constructor Injection) + Factory + SL + Coordinator allows to smooth out the above-described disadvantages of service locator.

But then a reasonable question arises — is there a need for a service locator at all? Why not abandon it completely and create dependencies inside factories, after all, factories are conceived to encapsulate creational logic. I leave this question for you to think about it.

Part 3. DI Libraries

So, it’s time to take a look at the popular DI libraries. Let’s start with the classification. The DI libraries that we will look at will be based on either reflection or code generation.

Let’s start with reflection-based libraries. Reflection means mapping a set of keys to a set of objects. So, for example, in our service locator, we used a Dictionary, which essentially maps a key (object type name) to the service object itself.

Reflection-based libraries

Swinject

https://github.com/Swinject/Swinject

There are 4.5k stars on github. Probably the most popular DI library for iOS. Quite easy to use.

But wait! registerresolve! … Does this remind you of anything? This is very similar to the already familiar ServiceLocator.

If we look inside, we will see a similar dictionary, but the key is already the ServiceKey structure, and the value has the ServiceEntryProtocol type.

In our simple ServiceLocator example, we used a string with the type name as the key. Things are a little more complicated here. The service type (serviceType) is used in the same way, but the types of arguments, passed in the resolution, are also used. Name (name) — used for adding equal type dependencies under different names. Options (option) — to use a container with storyboards. All these parameters are needed to get a unique hash value for the different dependencies that we register.

Value is no longer a service object as it was in our simple service locator. Behind this protocol is the ServiceEntry class, which knows how to create an object, where and how to store it (depends on the scope), and also contains the necessary data for resolving circular dependencies (using initCompleted).

Сircular dependencies

What are the circular dependencies? To understand what it is, you first need to understand how the container creates dependencies. When you ask a container for a dependency of a certain type, it can also have dependencies, which can also have dependencies, and so on. When creating a dependency, the container builds a directed graph of dependencies, based not only on the connections between them but also on the type of their scope.

Swinject provides 4 types of scopes:

  1. Graph
  2. Transient
  3. Container
  4. Weak

Graph

Default scope in Swinject. The dependency is resolved within the constructed dependency graph. For example:

For each resolution of a1 and a2, separate graphs are built:

If you run this code, you will catch the error at runtime, because you will get an infinite loop of dependency creation. Such cases are resolved using .initCompleted, but we will discuss it later.

Transient

It is also called prototype or unique in other libraries. The dependency is recreated on every resolve.

Let’s register A with a prototype scope:

container.register(A.self) { r in    return A(b: r.resolve(B.self)!)}
.inObjectScope(.transient)

We get the following graph for resolving:

Container

It is also called Singleton. I think the essence is clear — one instance per container and child containers. Created on the first resolution.

Let’s register B as a container:

container.register(B.self) { r in    return B(c: r.resolve(C.self)!)}
.inObjectScope(.container)

We get the following graph for resolving:

Weak

It’s WeakSingleton. The dependency will continue to exist as long as there is a strong external reference to it. With a graph, I think it’s clear, similar to the container scope.

Resolving circular dependencies

Resolving circular dependencies is a must-have feature for a DI library. In Swinject, it is implemented through deferred property injection inside the initCompleted method for one or more dependencies in a loop.

So it is not possible to use only constructor injection to break the loop.

For example, for the loop that we considered above, you can remake the C class to support property injection and add its implementation to initCompleted.

final class C {     var a: A?}container.register(C.self) { _ in return C() }    .initCompleted { r, c in         c.a = r.resolve(A.self)    }

When resolving cyclic dependencies in Swinject, the dependency can be created several times, which affects the time of the resolution and on large cycles can vastly affect the launch time of the application. Also, it is impossible to say exactly which of the created objects will be used. In such cases, it is better to place all resolves in the loop in .initCompleted, which will help avoid this problem (more details here).

Swinject Autoregistration

Swinject also provides a simplified syntax for registration — Autoregistration.

So instead of writing like this:

It can be written much shorter:

container.autoregister(MyServiceProtocol.self, initializer: MyService.init)

A convenient feature, but you have to pay for convenience with speed. Using auto-registration increases the resolution time, and hence the application launch time, and on large projects with a very large number of dependencies, this will be critical.

How critical it is — we will find out a little later, but for now I will offer an alternative that is slightly shorter than the usual syntax, but faster than auto-registration.

Now we can write registration a little shorter:

In addition to all the above-mentioned features, Swinject also provides the ability to:

1) Build container hierarchies;

2) Split registration into modules (Assembly);

3) Ability to work with storyboards;

4) Thread Safety:

The container is not thread-safe by default, but the synchronize method gives us a thread-safe resolver:

let threadSafeContainer: Resolver = container.synchronize ()

Сons of Swinject

The main disadvantage of Swinject is that it can be used as a service locator. In this case, it inherits all the problems described in the Service Locator section. Swinject also has a runtime nature with all the consequences. If you resolve a dependency that you haven’t registered, you will find it out on crashing at runtime. Someone might say that this is a degenerate case and that you never forget to register dependencies. But in reality, in large projects, this may well happen. For example, you may need to add an argument when resolving some existing dependency that is used in different modules. At the same time, from this moment on, with a resolution without an argument, you will also get a crash in runtime. Forgot to add an argument when resolving in some module? What does Swinject tell you about this? Unfortunately nothing.

DIP

https://github.com/AliSoftware/Dip

There are 888 stars on github. Also a popular library. Very similar to Swinject in terms of syntax.

It has almost the same feature set as Swinject, with a few differences:

  1. No support for container hierarchy and module registration splitting (Assembly)
  2. Default thread safety (can be disabled via an argument in DependencyContainer.init)
  3. There is a validation feature. It is possible to validate the container so that all dependencies that have been registered will be resolved without errors. It does not seem to be a very useful thing, because when we try to resolve something that we did not register, we will get the same error at runtime, and not during validation.
  4. In addition to the already described scopes, which Swinject has, it also adds EagerSingleton — “the impatient singleton”. The bottom line is that this is a singleton that is created when container.bootstrap() is called, and not when the singleton is first resolved. The bootstrap method fixes dependencies in the container, preventing new dependencies from being added or old ones being removed after calling it.

Internally, it is designed similar to Swinject:

The key also stores information about the type and arguments. A tag is analogous to the name field in Swinject and is used to register the same type under different tags.

Definition — a wrapper for a dependency that knows its type, how to create a dependency and knows its scope.

Just like Swinject, DIP can be easily used as a service locator with all that it implies. Some developers prefer to use DIP at the start of a project or on small projects, explaining that it is simpler than Swinject (controversial statement). When the “small project” grows later, they are horrified to notice that the application launch time has become very long. The point is that DIP is significantly slower than Swinject. Want to know how much? We will figure out it later.

DITranquillity

https://github.com/ivlevAstef/DITranquillity

There are 316 stars on github. The syntax is slightly different from Swinject and DIP. Firstly, it provides “auto-registration” out of the box, which is convenient, but probably affects performance, and secondly — resolve without having to use ! and try! — is also good.

Thread safety is also out of the box, you can’t disable it. Modularity and container hierarchies are available.

Several new scopes — perRun (one for each application launch), perContainer (one for each container) and single (of the singleton type, but created immediately after calling method .initializeSingletonObjects () of the container).

Graph validation is available, that is, the library can check the dependency graph before using it. When we register all the dependencies, get the graph, call the checkIsValid function — and it checks that everything is registered correctly, otherwise it throws an error with a link to the file and the line where the error occurs — it is also very convenient.

#if DEBUGif !container.makeGraph().checkIsValid(checkGraphCycles: true) {     fatalError(“invalid graph”)}#endif

Under the hood, in general, it is the same to the previous libraries, although at a glance it seems that design is more complicated than in Swinject and DIP.

Objects are stored in a special object behind the protocol DIStorage.

The key contains not only information about the type, but also the file and line where the dependency was registered. This is necessary just in order to produce accurate logs when checking the dependency graph.

The author of the library even compared the speed of his library with Swinject on a big number of registered objects with simple dependency graphs. Based on the results of these tests, DITranquility is faster than Swinject (here).

EasyDI

https://github.com/TinkoffCreditSystems/EasyDi

There are 88 stars on github. Differs from the previous libraries in terms of syntax. Unlike the previous ones, no longer has to use it as a service locator:

So we do not register or resolve dependencies explicitly, but create a special object — Assembly, which acts as a provider of dependencies and builds the dependency graph itself under the hood. All Assemblies exist within some DIContext context.

As you can see, the default DIContext is static and created by default. It controls the dependency graph of all child Assemblies hierarchy. The dependency graph itself is implemented using a dictionary. The key in this case is the name of the Assembly type + the name of the variable of our dependency. And the object is just Any.

public typealias InjectableObject = Any

Provides 4 scopes already familiar to us: prototype, objectGraph, lazySingleton, a recently added weak singleton. Additionally, you can build Assembly hierarchies. And also provides a feature of substitution of objects for testing.

The library is pretty easy to use and implement and takes just over 200 lines of code.

In addition, we get thread safety out of the box. There is no explicit phase of registering dependencies, so we can not forget to register what we need.

Pros and cons of reflection libraries.

Pros:

  1. Easy to use, low entry threshold;
  2. The implementation is relatively easy to figure out if needed.

Cons:

  1. All the libraries described above (except EasyDI)) are easy to use as service locators;
  2. All the magic happens in runtime, which means we get errors in runtime and not all libraries have the ability to validate the graph;
  3. The launch time of the application is affected (again due to its runtime nature).

Despite the number of drawbacks, they remain the most popular DI solutions in iOS.

Libraries based on code generation

Let’s see how such libraries work. In simple terms, you mark the property of your class with a certain attribute or inherit your it from a specific protocol, and the library (or, in this case, rather a framework) generates the code that injects these properties/dependencies. And that’s all. You don’t have to do anything else — just use your dependencies.

If it is not clear yet — no problem, let’s look at examples.

Needle

https://github.com/uber/needle

There are 822 stars on github. An increasingly popular library from the Uber developers. Powered by SourceKit.

The main advantage, according to the developers, is the compile time safety of the dependency injection code.

Dependency containers must inherit from the Component class. Dependencies of a DI container are described in a protocol that inherits from the underlying Dependency protocol and is specified as the generic type of the container itself.

In the example, we can also see how child components are created in the parent component. Very similar to Assembly in EasyDI, right? But they work completely differently under the hood, though. Each time you build a project, the Needle code generator analyzes the source code of the project and looks for all inheritors of the base classes for the Component DI containers.

Each DI container has a dependency description protocol. For each such protocol, the generator analyzes the availability of each dependency by searching for it among the container’s parents. The search goes from bottom to top, i.e. from child component to parent. A dependency is considered found only if the name and type of the dependency match.

If the dependency is not found, then the project build stops with an error indicating the lost dependency. This gives the compile-time safety stated above.

Pros:

  1. Compile-time safety — all validation is compile-time;
  2. Application launch time is not affected by the increase in the number of dependencies;
  3. Thread safety out of the box;
  4. Component hierarchy support.

Cons:

  1. So far, there are only 2 scopes — shared (essentially container) and prototype, but in theory, you can get by with them;
  2. The entry threshold is a little higher than that of service locators.

Needle is a good alternative to Swinject. Despite the latest version number v0.17.1, the developers say that the library is production-ready and is already used in all Uber applications.

Weaver

https://github.com/scribd/Weaver

There are 543 stars on github. Also based on code generation, uses SourceKitten based on SourceKit. But it works a little differently than Needle, via annotations to properties. Annotations in our case are comments of a certain format or special propertyWrappers.

For example:

I don’t like the first option because of the comments “fragility”, but this option was the only one before the appearance of property wrappers in Swift 5.1. The second option was expected after their appearance.

Weaver scans the project code for annotations, builds a dependency graph, and generates everything that is needed for DI, namely the MainDependencyContainer container, from which you can get the DependencyResolver for each dependency.

There are three types of annotations in Weaver — register,reference и parameter

1) Register

Adds a dependency to the container and provides access to the dependency.

2) Reference

Only provides access(reference) to the dependency.

3) Parameter

A parameter that will be passed to the object during initialization.

All objects where we add annotations must provide a standard init with an argument with the resolver of the corresponding generated type, which looks like a crutch.

required init(injecting _: MyDependencyResolver) {    // no-op}

The library provides us with the already familiar 4 scopes:

  1. transient
  2. container
  3. weak
  4. lazy

Pros and cons of Weaver

Pros:

  1. Compile-time safety;
  2. Knows how to generate stubs for tests.

Cons:

  1. The library “forces” us to use PropertyInjection;
  2. A crutchy constructor for all dependencies;
  3. The entry threshold is higher than needle has — unintuitive to understand register/reference/parameter;
  4. The objects into which we inject dependencies depend on the nuances of the library, which means that you cannot change the library for another without changing the code of the objects themselves.

Pros and cons of code generation libraries

Pros:

  1. Compile-time safety, getting rid of crashes in runtime;
  2. Do not affect application launch speed;

Cons:

  1. Slightly harder to understand than register/resolve containers;
  2. Objects depend on the nuances of the library (they inherit special protocols or use special attributes from fields)

Part 4. Battle of containers

Let’s try to compare the performance of the libraries discussed above on an increasing number of dependencies.

Let each registered dependency have from 0 to 3 dependencies, some of which also depend on each other. We will do the measurements on the iPad 2017 (the hardware analog of the iPhone 6s) iOS 13.5. (Time in seconds) in the release configuration (with optimizations).

So what do we have?

  1. DIP is an outsider in this competition;
  2. Swinject and DITranquility are the fastest at 100 registrations, but degrades when registrations grow to 1000. However, DITranquility degrades more slowly;
  3. SwinjectAutoregistration makes Swinject a little slower;
  4. EasyDI is slower than Swinject and DITranquility at 100 registrations, but the growth of registrations weakly affects the time of its work. And at 1000 registration it becomes faster than Swinject;

After the optimizations in version 4.2, the performance of DITranquility has increased significantly. Prior to version 4.2, the performance will be lower.

It would seem that we have found our winners and you can go and use them in your projects! But no, it’s not so simple. We were looking at a simple dependency graph. Each recorded dependency had between 0 and 3 dependencies. This is quite small and the situation is clearly worse in large and complex projects. Let’s look at some “worst” case versus the simple one. Let’s say each registered dependency will have 10 of its own dependencies, which are also dependent on each other.

As you can see, the situation has changed. Swinject still degrades with the growth of registrations, but its time is noticeably shorter than other libraries except DITranquility. Apparently, working with a complex graph in Swinject and DITranquility is better optimized. And DITranquility is faster than Swinject when it has already reached 500 registrations.

I would like to note that the time of a single library is in itself not very informative, only their values ​​relative to each other are important, because:

  1. During the tests, the average time for measuring 100 tests in a row was calculated. And the first iteration was usually slower than the subsequent ones.
  2. Objects registered in tests are empty and do not contain business logic, which means their creation time is less than that of objects in real projects.

Since projects usually become more and more complex, more and more new features are added, sooner or later an increase in the number of registrations and the complexity of the dependency graph can lead to a critically long application launch time. Perhaps in such cases, it makes sense to switch to a DI library based on code generation, because they have all the “magic” happening at compile-time, not at runtime.

But with libraries for code generation, it’s not so simple either)

Let’s see how the increase in the number of dependencies affects compile time. Consider the already mentioned Needle and Weaver.

What a twist! Even though both libraries are based on SourceKit, compilation times differ by an order on a large number of dependencies. This example shows how important optimization is in such libraries.

Conclusion

  1. Many of the popular iOS DI libraries allow you to use them as service locators, that is, so in some sense, they are closer to service locators than to DI containers;
  2. Many DI libraries are based on common principles and tools, but common principles can be implemented in different ways. For example, the running time of containers based on reflection is very different and depends not only on the number of dependencies but also on the complexity of the dependency graph itself (well, on the implementation of the container itself, of course). Or, for example, the compilation time for libraries for code generation is also very different;
  3. Optimization of the DI libraries is very important, as it affects the launch of the application or its build time.

Which library to choose for your project?

Depending on the situation, on small projects Swinject might be a good option. If you are already using Swinject (or any other register/resolve library) and you are critically unhappy with the application startup time, take a closer look at Needle.

And you can always make your own DI. This can be done quite simply, for example, through factories, but that’s a completely different story …

Links to test projects

  1. https://github.com/vitalybatrakov/iOS_DI_Libs_Performance_Tests
  2. https://github.com/vitalybatrakov/iOS_DI_Libs_Compilation_Time_Tests

--

--