Magic Dependency Injection in Swift

In Binge for iOS and tvOS we decided to use a custom Swift Property Wrapper to help us inject dependencies into classes and ensure even singleton-esque objects can be mocked in unit tests.

Max Chuquimia
Streamotion Tech Blog
9 min readJun 16, 2021

--

Here’s what we’re aiming for by the end of this article.

The Problem

Supporting multiple products that each have iOS and tvOS counterparts would quickly become a nightmare without clearly defined rules about how shared code (both internal and third party frameworks) can be used and mocked in unit tests. If you’re unfamiliar with a few standard ways to implement Dependency Injection, here’s a little refresher:

Types of Dependency Injection

No Injection
It can be tempting to make all your shared logic classes into Singletons — that is, store a static “shared” instance on the class that can be accessed easily by everyone:

There are many issues with this approach, most notably the lack of mockability in unit tests. How could you write a test for a class that makes use of this shared instance without the tests actually accessing the internet?

Initializer Injection
Initializer injection is a reasonable solution. It involves passing instances of dependencies into a class’ init.

By using a protocol (e.g. NetworkManagerInterface) that lists all the public function of a class, in a unit test we can conform it when creating mocks and then pass those into SomeViewModel in a unit test. Now our tests won’t access the internet!

There is an issue with this approach: long initializers mean many lines of boilerplate code need to be written and maintained throughout the project, reducing legibility.

Using a Registry

At a high level, an advanced dependency injection solution must allow a developer to list out (aka “register”) object instances / initializers against a key and then provide the ability to look up (aka “resolve”) from the registry using only that key.

The most basic advanced dependency injection solution might be to store all possible object initializers (aka closures that are capable of creating an object) in a dictionary and then look them up in other classes:

Of course, this is less than ideal. At a minimum we should have type safety for both keys and values!

Our @Injected property wrapper does use an underlying dictionary, however it manages to abstract away the dirty casting by making use of Generics.

Building our @Injected solution

Property Wrappers

Swift’s @propertyWrapper attribute marks a struct or class as having the ability to be its own annotation for another class’ properties. This gives us the ability to hijack the annotated property’s get and set and provide our own implementation. In the case of @Injected, we use the property wrapper to turn the wrapped variables into get-only computed properties.

As a simplistic example, here’s a custom a property wrapper that makes any wrapped Date property always return Date():

The wrappedValue variable is a requirement of the @propertyWrapper that is decorating the class. As we have not made wrappedValue settable, any property annotated with @CurrentDate will also not be settable.

The underlying implementation of @Injected is based on this concept — except we look up the value to return from our “dependency store”.

Injected.swift

Brace yourself, there’s not much code here!

What’s going on?

  • The storage variable allows us to retain and return the instance retrieved from the dictionary. We don’t want to initialise a new instance of the registered object every time the wrapper property is referenced!
  • The dependencyStore property stores an instance of our registry. When @Injected is initialised, we store a reference to the singleton instance of the registry (more about why later).
  • The wrappedValue property, as before, is get-only. If storage is not set then it asks the registry for something of type T via the dependencyStore.resolve() function. More on this later.

The @Injected property wrapper is generic so it can be applied to any type. We now have a developer-friendly way of looking up values in a registry!

Always start with the interface you want and work backwards into implementation from there.

Now let’s take a peek at how DependencyStore works.

DependencyStore.swift

Actually, this is where the protein is:

Again, what’s going on?

  • First we have a singleton instance of the class stored in shared as we need to use the same dependency store throughout the app. This is a var because of unit tests… more about why later (in reality we wrap it with #if DEBUG and ensure its a let when building for the AppStore, don’t worry!)
  • The store variable is our table of strings-to-initializers that we saw in the simplistic example earlier.
  • The register(_:for:) function is our gateway to inserting initializers into store. It converts T.Type from the for: argument to a string for the key, and marks the dependency as an auto closure for our convenience later on. This makes everything type safe as its impossible to register a type with a wrong key!
  • The resolve() function infers its type from the value being returned. Again, it creates a string using T.Type and looks for it in the store. It then calls the (auto) closure to get the real instance and returns it (to the wrappedValue of @Injected).

And that is the basics of how it all fits together! In our app delegate we register all our dependencies using the register(_:for:) function, and then we can look them up later using the @Injected property wrapper:

Due to the first argument of register(_:for:) being an auto closure, AuthManager and NetworkManager are lazy : they won’t be instantiated until the first time an @Injected property with their protocol interface is accessed. This does mean that they will be released when the owner of that @Injected property is released, so if we want singleton-like behavior for one of them we can simply create it first:

And of course, using a protocol in the for: is not at all required — but it is highly recommended as it means that mock objects in unit tests can conform to the public protocol interfaces of objects rather than subclassing real objects.

Bells & Whistles

You may have noticed the fatalError that occurs when when DependencyStore.resolve() fails to retrieve an object from its store (because the object hasn’t been registered). This is a serious developer error that we think is worthy of such drastic measures — but what if there was another way to ensure we always register(_:for:) our dependencies before trying to @Injected them?

In an Xcode project’s run script phase its possible to tell Xcode to display warnings and errors on specific lines of code. For example, to add an error due do something on the first line of your AppDelegate.swift, simply add the following Bash line in a run script:

Hello!

Combine this with a non-zero exit code (to fail the build) and we can practically add language features using Bash! (well, no, but close enough for our purposes).

I’m not going to go into the nitty gritty of the Bash script (please visit the bottom of this article for a full copy) however at a high level it:

  • Finds all Swift files containing @Injected and extracts their type (using Regex)
  • Checks if the type exists in a register(_:for:) function (again using Regex)
  • Echos an error if it does not (or if we register something and don’t use it)

Trust me, it’s not as simple as those three points (supporting injections in both local and remote Swift Packages was a nightmare). The result, however, is well worth the effort:

💡 Hold down option whilst performing any action that switches files in Xcode and it will open the file in the secondary editor

The top editor shows our list of registrations with an error due to line 41 being commented out in the bottom editor. The bottom editor is a typical class showing an error due to line 243 being commented out in the top editor. Our registration list looks like this:

Then all our App Delegate needs to do is call DependencyStore.shared.registerAll() immediately upon app launch. And, as mentioned before, this file can be easily read as the DEPENDENCY_REGISTRATION_FILE environment variable in our script.

Mocking within Unit Tests

Now that we have dependency injection all set up, how can we change what @Injected resolves when running in unit tests? Consider the following class:

As we saw in our previous examples, a real NetworkManager() was registered against the NetworkManagerProtocol. So, when setting up our unit test, we should first create a MockNetworkManager that conforms to NetworkManagerProtocol and then register it just before creating the AuthManager instance that we want to test:

This works because now the @Injected in AuthManager will resolve the mock network when it looks up the network manager protocol. However, this isn’t a great solution as it means subsequent tests will be run with a DependencyStore.shared that still has mocks hanging around from previously run tests. And what if tests are being run concurrently?

Our final solution was to create a new instance of DependencyStore, register mocks against it and then change the DependencyStore.shared instance just before creating a class. We made a handy function to encapsulate this logic:

  • syncingQueue is used to ensure the logic within the execute(_:) function isn’t run from two places at once.
  • execute(_:) takes a closure initialisations that returns a generic type and then returns that same generic type to its caller.
  • Within execute(_:), we store the default DependencyStore.shared instance, replace it with self, run the initialisations closure and then restore DependencyStore.shared to its original value

Using this helpful function we can now re-write our previous test code without re-registering objects on DependencyStore.shared:

  • We create a new DependencyStore that is just for this test
  • We register our network mock on it
  • We then move our AuthManager instantiation into the execute(_:) closure so that it is created while DependencyStore.shared was swapped to be our local instance d

That’s a wrapper!

After we started implementing our @Injected property wrapper we came across this article (which ended up helping quite a bit with some implementation nuances, so do check it out). Overall, writing @Injected was quite a difficult undertaking so hopefully it helps or inspires you in some way.

Thanks for tuning in to this first article from the Streamotion iOS team! Be sure to follow this publication if you’re interested in future snippets from Kayo, Binge and more!

--

--

Max Chuquimia
Streamotion Tech Blog

Principal Software Engineer, Electronics Enthusiast, Appreciator of Music