Dependency Injection: Why Did We Choose Needle

Joom
Joom
Nov 17 · 12 min read

Hi! My name is Anton, I am an iOS developer at Joom. In this article, I will tell you how we work with the Needle DI framework and if it really has an obvious advantage over similar solutions and is ready for use in production code. Sure, we will do performance tests along the way.

Image for post
Image for post

Background

Back in the days when iOS apps were still written entirely in Objective-C, there weren’t many DI frameworks. Typhoon was considered as the default standard. Even though Typhoon has lots of great points, it also brought a certain runtime overhead that led to a loss of performance in the app.

At the dawn of Joom’s development, we tried to use this solution but its performance in test measurements was not satisfactory, so we decided to abandon it in favor of our own solution. It was so long ago that only one person from our current iOS team saw those times, and the described events were restored from his memories.

Then Swift replaced Objective-C and more and more applications began to switch to this new language. Well, what about us?

While everyone was switching to Swift, we continued to write in Objective-C and to use a self-written DI solution. It had everything we were looking for in a dependency injection tool, i.e. performance and reliability.

The high performance was assured by the fact that there was no need to register any dependencies at runtime. The container consisted of ordinary properties that could be provided, if necessary, in different forms:

  • a regular object that is created each time the dependency is accessed;
  • a global singleton;
  • a singleton for a specific combination of an input parameters set.

In this case, all child containers were created through the property of the parent containers. In other words, our dependency graph was built at compile time of the project, and not at runtime.

Reliability was ensured due to the fact that all tests were performed at compile time. Therefore, if somewhere in the header of the container we declared a dependency and forgot to create it during implementation, or the dependency did not have any property at the point of access to it, then we could find it out at compile time of the project.

This solution had one drawback that was the bane of our existence.

Imagine that you have a graph of DI containers and you need to transfer a dependency from a container in one branch of the graph to a container from another branch of the graph. At the same time, the depth of the branches can easily reach 5–6 levels.

Here is the list of what had to be fixed in our solution to forward one dependency from the parent container to the child container:

  • make a forward declaration of the type of the new dependency in the .h file of the child container;
  • define the dependency as an input parameter to the constructor in the .h file of the child container;
  • make #import header with dependency type in child container .m file;
  • define the dependency as an input parameter of the constructor in the .m file of the child container;
  • define a property in the child container where we will put this dependency.

Pretty much to do, right? And this is only for forwarding one level below.

Obviously, a half of these actions are required by the very ideology of splitting the code into header files and files with implementation in the C languages. But this became a headache for the developer and reduced his job essentially to a mindless set of copy-paste actions that kill any motivation in the development process.

As an alternative to forwarding one specific dependency, you can pass the entire container with dependencies. This worked in the cases when we understood that other dependencies might be needed from the passed container in the future. And we often did that.

But this is the wrong way. In this case, one object gets more knowledge than it needs to work. We all went through interviews where we talked about the principles of SOLID, the precepts of Uncle Bob etc. and we know we shouldn’t do that. And for a long time we lived only with this solution and continued to write in Objective-C.

At the beginning of 2020, we made the final decision to transfer the development of new features to Swift and gradually get rid of the Objective-C legacy.

In terms of DI, it is time to take another look at the available solutions.

We needed a framework that would have the same advantages as our self-written Objective-C solution and would not require writing a large amount of boilerplate code.

There are currently lots of DI frameworks in Swift. The most popular ones are Swinject and Dip. But these solutions have some problems, in particular:

  • The dependency graph is created at runtime. Therefore, if you forgot to register a dependency, you will discover it at the moment of crash that will occur when you run the application and access the dependency.
  • Dependency is registered at runtime, which increases the application startup time.
  • To get a dependency in these solutions, you have to use such language constructs as force unwrap! (Swinject) or try! (Dip) to get dependencies that don’t make your code better or more reliable.

Obviously, that did not suit us so we decided to look for alternative solutions. Fortunately, we came across a fairly new DI framework called Needle.

General information

Needle is an open-source solution developed in Swift by Uber in 2018 (the first commit on May 7th, 2018).

According to developers, its main advantage is the compile time safety of the dependency injection code.

Let’s see how all this works.

Needle has two main parts: the code generator and the NeedleFoundation framework.

Code generator

A code generator serves to parse the DI code of your project and generate a dependency graph based on it. It’s powered by SourceKit.

At runtime, the generator builds connections between containers and checks the availability of dependencies. As a result of its operation, each container will get its own DependencyProvider, the main purpose of which is to provide the container with dependencies from other containers. We will discuss it in more detail below.

But the main thing is that if any dependency is not found, the generator will generate an error indicating the container and the type of dependency that was not found.

The generator itself is supplied in binary form. You can obtain it in two ways:

  1. By using the homebrew utility:

brew install needle

2. By cloning the project repository to find it inside:

git clone https://github.com/uber/needle.git & cd Generator/bin/needle

To connect to the project, you need to add a Run Script phase, in which you should just specify the path to the generator and the path to the file where the generated code will be placed. Here’s an example of such a setting:

export SOURCEKIT_LOGGING=0 && needle generate ../NeedleGenerated.swift

../NeedleGenerated.swift is a file that will contain all the generated code for building the dependency graph.

NeedleFoundation

NeedleFoundation is a framework that provides developers with a set of base classes and protocols for creating dependency containers.

It’s installed seamlessly through one of the dependency managers. Here’s an example of adding using CocoaPods:

pod ‘NeedleFoundation’

The graph itself begins to be built with creating a root container, which must inherit a special class BootstrapComponent.

The rest of the containers must be inherited from the Component class.

Dependencies of a DI container are described in a protocol that is inherited from the basic protocol Dependency and is specified as the generic type-a of the container itself.

Here is an example of such a container with dependencies:

protocol SomeUIDependency: Dependency {     var applicationURLHandler: ApplicationURLHandler { get }     var router: Router { get }}final class SomeUIComponent: Component<SomeDependency> {     ...}

If there are no dependencies, then a special protocol is indicated <EmptyDependency>.

All DI containers contain lazy properties path and name:

// Component.swiftpublic lazy var path: [String] = {     let name = self.name     return parent.path + ["\(name)"]}()private lazy var name: String = {     let fullyQualifiedSelfName = String(describing: self)     let parts = fullyQualifiedSelfName.components(separatedBy: ".")     return parts.last ?? fullyQualifiedSelfName}()

These properties are needed to form the path to the DI container in the graph.

For example, if we have the following container hierarchy:

RootComponent->UIComponent->SupportUIComponent,

Then for SupportUIComponent the path property will contain [RootComponent, UIComponent, SupportUIComponent].

During the initialization of the DI container, the constructor retrieves the DependencyProvider from a special register, which is represented as a special singleton object of the __DependencyProviderRegistry class:

// Component.swiftpublic init(parent: Scope) {     self.parent = parent     dependency = createDependencyProvider()}// ...private func createDependencyProvider() -> DependencyType {     let provider = __DependencyProviderRegistry.instance.dependencyProvider(for: self)     if let dependency = provider as? DependencyType {          return dependency     } else {          // This case should never occur with properly generated Needle code.          // Needle's official generator should guarantee the correctness.          fatalError("Dependency provider factory for \(self) returned incorrect type. Should be of type \(String(describing: DependencyType.self)). Actual type is \(String(describing: dependency))")     }}

To find the right DependencyProvider in __DependencyProviderRegistry the above mentioned property of the path container is used. All lines from this array are concatenated and form a final line that reflects the path to the container in the graph. Then a hash is taken from the final line and the factory that creates the dependency provider is extracted from it:

// DependencyProviderRegistry.swiftfunc dependencyProvider(`for` component: Scope) -> AnyObject {     providerFactoryLock.lock()     defer {          providerFactoryLock.unlock()     }     let pathString = component.path.joined(separator: "->")     if let factory = providerFactories[pathString.hashValue] {          return factory(component)     } else {          // This case should never occur with properly generated Needle code.          // This is useful for Needle generator development only.            fatalError("Missing dependency provider factory for \(component.path)")     }}

The resulting DependencyProvider is written to the property of the dependency container, thanks to which you can get the required dependency in the external code.

There is an example of referring to a dependency:

protocol SomeUIDependency: Dependency {     var applicationURLHandler: ApplicationURLHandler { get }     var router: Router { get }}final class SomeUIComponent: Component<SomeDependency> {     var someObject: SomeObjectClass {          shared {             SomeObjectClass(router: dependecy.router)          }     }}

Now let’s look at where the DependencyProvider comes from.

DependencyProvider Creation

As it was mentioned above, for each DI container declared in the code, its own DependencyProvider is created. This is due to code generation. The Needle code generator analyzes the source code of the project and looks for all inheritors of the base classes for the BootstrapComponent and 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 one.

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 is the first level of compile-time safety.

After all the dependencies in the project are found, the Needle Code Generator creates a DependecyProvider for each DI container. The resulting provider responds with the appropriate dependency protocol:

// NeedleGenerated.swift/// ^->RootComponent->UIComponent->SupportUIComponent->SomeUIComponentprivate class SomeUIDependencyfb16d126f544a2fb6a43Provider: SomeUIDependency {     var applicationURLHandler: ApplicationURLHandler {          return supportUIComponent.coreComponents.applicationURLHandler     }     // ...}

If for some reason at the stage of building connections between containers a dependency was lost and the generator missed this moment, then at this stage you will receive a project that won’t be assembled, since the broken DependecyProvider will not comply with the dependency protocol. This is the second level of compile-time safety from Needle.

Now let’s look at the process of searching for a dependency provider for a container.

DependencyProvider Registration

Having received the DependecyProvider and understanding the relationship between the containers, the Needle code generator creates a path for each container in the final graph.

Each path is mapped to a closure factory, within which a dependency provider is returned. The mapping code is generated by the code generator.

As a result, the global function registerProviderFactories() appears. We have to call it in our code before the first call to any DI containers.

// NeedleGenerated.swiftpublic func registerProviderFactories() {    

__DependencyProviderRegistry.instance.registerDependencyProviderFa
ctory(for: "^->RootComponent") { component in
return EmptyDependencyProvider(component: component) } __DependencyProviderRegistry.instance.registerDependencyProviderFa
ctory(for: "^->RootComponent->UIComponent") { component in
return EmptyDependencyProvider(component: component) } // ...}

The registration inside the global function is done with the help of a singleton object of the __DependencyProviderRegistry class. Inside this object, dependency providers are added to the dictionary [Int: (Scope) -> AnyObject] where the key is the hashValue from the line that describes the path from the top of the graph to the container, and the value is the closure factory. The entry itself is thread-safe as it’s used inside NSRecursiveLock.

// DependencyProviderRegistry.swiftpublic func registerDependencyProviderFactory(`for` componentPath: String, _ dependencyProviderFactory: @escaping (Scope) -> AnyObject) {     providerFactoryLock.lock()     defer {          providerFactoryLock.unlock()     }     providerFactories[componentPath.hashValue] = dependencyProviderFactory}

Test results

We currently have around 430k lines of code excluding third-party dependencies. About 83k of these lines are written in Swift.

All measurements were carried out on iPhone 11 with iOS 13.3.1 and using Needle version 0.14.

The tests compared two branches: the current develop and the branch in which the root container and all its child containers were rewritten to needle containers, and one container branch in the graph was completely replaced with Needle. All changes for the tests were made in this particular branch of the graph.

Performed tests

Complete assembly time

Image for post
Image for post

Mean value without Needle: 283.58s

Mean value with Needle: 289.7s

As we can see, the time for the initial analysis of the project code, which the Needle code generator should carry out, gave us +6 seconds to the time of a clean build from scratch.

Incremental assembly time

Image for post
Image for post

Mean value without Needle: 35.8s

Mean value with Needle: 35.48s

In this test, we added and removed the same dependency to the container at the very bottom of the graph.

registerProviderFactories() tests

Mean value (seconds): 0.000103

Tests:

0.0001500844955444336

0.0000939369201660156

0.0000900030136108398

0.0000920295715332031

0.0001270771026611328

0.0000950098037719726

0.0000910758972167968

0.0000970363616943359

0.0000969171524047851

0.0000959634780883789

This test showed that the time to launch our application when using Needle barely changed.

First dependency access tests

Image for post
Image for post

Mean value without Needle (seconds): 0.000085

Mean value with Needle (seconds): 0.001143 (+0.001058)

Mean value with Needle + FakeComponents (seconds): 0.002566

Note:

SomeUIComponent in the tested example lies at the seventh level of graph nesting:

^->RootComponent->UIComponent->SupportUIComponent->SupportUIFake0Component->SupportUIFake1Component->SupportUIFake2Component->SupportUIFake3Component->SomeUIComponent

In this test, we measured the rate at which the depency was initially accessed. As we can see, our self-written solution wins dozens of times here. But if you look at the absolute numbers, this is a very small time.

Test of the re-access to BabyloneUIComponent with Needle:

Image for post
Image for post

Mean value without Needle (seconds): 0.000044

Mean value with Needle (seconds): 0.000058

Mean value with Needle + FakeComponents (seconds): 0.000091

Re-accessing to the dependency is even faster. Our solution wins once again, but the absolute numbers are also very small.

Conclusion

Based on the test results, we came to the conclusion that Needle gives us exactly what we wanted from a DI framework.

  • It gives us reliability by providing compile time safety for the dependency code.
  • It’s fast. Not as fast as our self-written Objective-C solution, but still fast enough for us in absolute numbers.
  • It releases us from the need to manually introduce dependencies through many levels in the graph due to our own code generation. It is enough to implement the creation of a dependency in one container and declare the need for it in another container through a special protocol.

Nonetheless, there is one problem that occurs when using Needle. At the start of the application we need to make some settings for the dependency code. But as the test showed, the increase in startup time was less than a millisecond and we are ready to live with it.

In our opinion, Needle is great for teams and projects that care, like us, about the performance and reliability of the application, as well as the usability of its codebase.

Joom

Global mobile marketplace

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store