4.
Objects & Their Dependencies
Written by René Cacheaux & Josh Berlin
Ready to dig deep into object-oriented programming? Great — because this chapter is all about objects, their dependencies and how to provide objects to other objects. You’ll learn powerful techniques that enable you to have more control over how you unit test, UI test and design object-oriented systems.
Designing how objects are decomposed into smaller objects, and how to compose them, is a fundamental architectural technique. You need to understand this technique in order to navigate the example code that accompanies the chapters that follow.
In this chapter, you’ll first learn the benefits of managing object dependencies. Then, you’ll take a quick look at common dependency patterns. Finally, you’ll spend the rest of the chapter taking a deep dive into Dependency Injection, one of the common dependency patterns. Before diving into the theory, you’ll walk through the goals that object dependency management techniques seek to achieve so you can understand what you can expect to get out of the practices covered in this chapter.
Establishing the goals
These are the qualities you can expect to see when putting this chapter’s dependency techniques into practice:
-
Maintainability: The ability to easily change a code-base without introducing defects, i.e., the ability to reimplement part of a code-base without adversely affecting the rest of the code-base.
-
Testability: Deterministic unit and UI tests, i.e., tests that don’t rely on things you can’t control, such as the network.
-
Substitutability: The ability to substitute the implementation of a dependency at compile-time and at runtime. This quality is useful for A/B testing, for gating features using feature flags, for replacing side-effect implementations with fake implementations during a test, for temporarily swapping in a diagnostic version of an object during development and more.
-
Deferability: Having the ability to defer big decisions such as selecting a database technology.
-
Parallel work streams: Being able to have multiple developers work independently on the same feature at the same time without stepping on each others toes.
-
Control during development: A code-base that developers can quickly iterate on by controlling build and run behavior, e.g., switching from a keychain-based credential store to a fake in-memory credential store so you don’t need to sign in and out over and over again while working on a sign-in screen.
-
Minimizing object lifetimes: For any given app, the less state a developer has to manage at once, the more predictably an app behaves. Therefore, you want to have the least amount of objects in-memory at once.
-
Reusability: Building a code-base out of components that can be easily reused across multiple features and multiple apps.
Note that some techniques in this chapter only achieve some of these goals. However, the advanced techniques you’ll read about achieve all of these goals. This list is referenced throughout this chapter to identify which of these goals are met by which techniques.
Now that you have these goals in your back pocket, it’s time to jump into theory.
Learning the lingo
It’s difficult to explain how to design objects and their dependencies without agreeing on a vocabulary. Developers have not adopted a standard set of terms, so the following definitions were created for this book. Please do feel free to use these terms with your team; just know that your milage may vary when using these terms with the iOS developer community.
Typically, when app developers use the term dependency, they are talking about libraries. However, in this chapter, a dependency is an object that another object depends on in order to do some work.
A dependency can also depend on other objects. These other objects are called transitive dependencies.
The object-under-construction is the object that depends on dependencies.
The reason an object goes under construction is to be used by yet another object — the consumer.
All together, you have a consumer that needs the object-under-construction that depends on dependencies that depend on transitive dependencies and so on.
The relationships between these objects form an object graph.
While reading about dependency patterns in the following sections, you’ll see the terms outside and inside. Outside refers to code that exists outside the object-under-construction. Inside refers to the code that exists inside the object-under-construction. As you’ll see, this distinction is architecturally significant.
That’s the terminology you’ll need to know to follow along this deep and winding object-dependency journey. Reviewing when and how dependencies are created will help you understand why dependencies exist in the first place, so that’s next.
Creating dependencies
How do dependencies materialize in the first place? Here’s a couple of common scenarios.
Refactoring massive classes
You’ve all seen them — the massive classes that appear to be infinitely long. Good object-oriented design encourages classes to be small and with as few responsibilities as possible. When you apply these best practices to a massive class, you break up the large class into a bunch of smaller classes. Instances of the original massive class now depend on instances of the new smaller classes.
Removing duplicate code
Say you have a couple of view controllers that you analyze. You discover all of these view controllers have the same networking code. You extract the networking code into a separate class. The view controllers now depend on the new networking class. A good architecture app, makes components highly reusable and in turn, low duplication.
Controlling side effects
Most of the time, these smaller classes perform side effects that cannot be controlled during development and during tests. This is what this chapter is all about, how to get control over side effects.
How you do this refactoring has a direct impact on how many of the outlined goals you can achieve. There are three fundamental considerations that will help you achieve the goals.
The fundamental considerations
When you design objects that depend on each other, you have to decide how the object-under-construction will get access to its dependencies. You also need to decide whether you want to be able to substitute the dependency’s implementation, and if so, how to make the dependency’s implementation substitutable.
Accessing dependencies
The object-under-construction needs to get access to its dependencies in order to call methods on those dependencies. Here are the ways an object-under-construction can get a hold of its dependencies.
From the inside:
-
Global property: The object-under-construction can simply access any visible global property.
-
Instantiation: If a dependency is ephemeral, i.e. the dependency doesn’t need to live longer than the object-under-construction, the object-under-construction can instantiate the dependency.
From the outside:
-
Initializer argument: A dependency can be provided to the object-under-construction as an initializer argument.
-
Mutable stored-property: A dependency can be provided to an already created object-under-construction by setting a visible mutable stored-property on the object-under-construction.
-
Method: Dependencies can be provided to the object-under-construction through visible methods of the object-under-construction.
Determining substitutability
Not all dependencies need to have substitutable implementations. For example, you probably don’t need to substitute the implementation of a dependency that has no side effects, i.e. it only contains pure business logic. However, if the dependency writes something to disk, makes a network call, sends analytic events, navigates the user to another screen, etc. then you probably want to substitute the dependency’s implementation during development or during testing.
Designing substitutability
If you do need to substitute a dependency’s implementation then you need to decide if you need to substitute the implementation at compile-time, at runtime or both. To illustrate, you’ll probably need runtime substitutability when you need to provide a different experience to different users for A/B testing. On the other hand, for testing, developers typically rely on compile-time substitutability.
You have the goals, the vocabulary and the main considerations — what’s next? You might be wondering why there’s so much talk about testing in an architecture book. That’s the next stop on this journey.
Why is this architecture?
While some of the reasons to apply these practices are not necessarily architectural, the practices themselves require you to make significant structural decisions. That’s why this material is in an architecture book.
In theory, you can design a good architecture without the techniques in this chapter. However, if you’re writing software using industry best practices, such as unit testing, you’ll definitely need to know about these techniques. On the flip side, these techniques are not a silver bullet. It’s also possible to design a poor architecture while using these techniques. This chapter is just one of many puzzle pieces you must master in order to design great software.
You’re now ready to learn how to take control of your objects’ dependencies. There are several patterns you can use to design objects and their dependencies. This chapter focuses on one of those patterns, but it’s worth taking a quick look at all the different patterns first.
Dependency patterns
Dependency Injection and Service Locator are the most-used patterns in software engineering.
-
Dependency Injection: This is the pattern you’ll learn all about in this chapter. The basic idea of the pattern is to provide all dependencies outside the object-under-construction. More on this later.
-
Service Locator: A Service Locator is an object that can create dependencies and hold onto dependencies. You provide the object-under-construction with a Service Locator. Whenever the object-under-construction needs a dependency, the object-under-construction can simply ask the Service Locator to create or provide the dependency. This pattern is easier to use than Dependency Injection but results in more work when harnessing automated tests. Many developers use this pattern to successfully achieve the goals outlined in this chapter.
Here are other patterns you can use that have been created by the Swift community:
-
Environment: An environment is a mutable struct that provides all the dependencies needed by objects-under-construction. This pattern is very similar to Service Locator; the only difference is an environment is accessed inside objects-under-construction, and a Service Locator is provided to the object-under-construction. This is a neat lightweight approach to managing object dependencies. To learn more, check out the Point Free Swift video series by Brandon Williams and Stephen Celis.
-
Protocol Extension: This pattern uses Swift’s protocol extensions to allow the object-under-construction to get access to its dependencies. To learn more about this pattern, see Daniel Hall’s article, A Swift-y Approach to Dependency Injection.
Now, the stage is set. You’ve got everything you need. It’s time to take a deep dive into the world of Dependency Injection.
Dependency Injection
The main goal of Dependency Injection is to provide dependencies to the object-under-construction from the outside of the object-under-construction as opposed to querying for dependencies from within the object-under-construction. Dependencies are “injected” into the object-under-construction.
Externalizing dependencies allows you to control dependencies outside the object-under-construction — in a test, for example. By externalizing dependencies, you can easily see what dependencies an object has by looking at the object’s public API. This helps other developers reason about your code, including your future self!
When developers hear “Dependency Injection,” they commonly think about Dependency Injection frameworks. However, Dependency Injection is first and foremost a pattern that you can follow with or without a framework. The best way to learn Dependency Injection is without using a framework. That’s why this book does not use a framework for Dependency Injection.
From here, you can learn about the history behind Dependency Injection or you can skip ahead and jump into the details.
History
Dependency injection, or DI, is not a new concept. Ask any Android developer if they are familiar with DI, and they will likely tell you DI is essential to building well-architected apps. Dependency injection is also heavily used when building Java backend applications. So, it’s no surprise that Java developers take advantage of the design pattern when moving to Android.
DI is intimately built into the core of some popular frameworks like AngularJS. The official AngularJS documentation has an entire section on the topic. The authors of the documents stressing that DI is “pervasive throughout Angular.”
The object-oriented theory behind DI has been around for a while. DI is based on the Dependency Inversion Principle, also known as Inversion of Control. According to Martin Fowler, Inversion of Control was first written about in 1988 in a paper titled Designing Reusable Classes by Johnson and Foote. Arguably, the concept was popularized by Robert Martin’s paper, Object Oriented Design Quality Metrics: An Analysis of Dependencies published in 1994. These papers are worth a read if you want to dig deep into the roots of object-oriented design.
The term Dependency Injection was coined by Fowler in his January 14, 2004, post titled, Inversion of Control Containers and the Dependency Injection Pattern. The increasing popularity of Agile and Test-Driven Development motivated developers to find ways to easily test object-oriented code. As a result, to meet testability needs, developers invented Inversion of Control and, more specifically, DI. As you’ll see, using Dependency Injection is key to building testable and maintainable iOS apps.
Types of injection
There are three types of injection:
-
Initializer: The consumer provides dependencies to the object-under-construction’s initializer when instantiating the object-under-construction. To enable this, you add dependencies to the object-under-construction’s initializer parameter list. This is the best injection type because the object-under-construction can store the dependency in an immutable stored-property. The object-under-construction doesn’t need to handle the case in which dependencies are nil and doesn’t have to handle the case in which dependencies change. Initializer injection isn’t always an option, so that’s when you would use property injection, the next injection type.
-
Property: After instantiating the object-under-construction, the consumer provides a dependency to the object-under-construction by setting a stored-property on the object-under-construction with the dependency. If you don’t have a default implementation for a property-injected-dependency, then you’ll need to make the property type
Optional. This injection type is usually used in Interface Builder-backed view controllers because you don’t have control over which initializer UIKit uses to create Interface Builder-backed view controllers. -
Method: The consumer provides dependencies to the object-under-construction when calling a method on the object-under-construction. Method injection is rarely used; however, it’s another option at your disposal. If a dependency is only used within a single method, then you could use method injection to provide the dependency. This way, the object-under-construction doesn’t need to hold onto the dependency. Remember, the less state an object has, the better. The shorter an object’s lifetime, the better.
A good rule of thumb: When the object-under-construction cannot function without a dependency, use initializer injection. If the object-under-construction can function without a dependency, you can use any type of injection, preferably initializer injection.
Circular dependencies
Sometimes, two objects are so closely related to each other that they need to depend on one another. For this case to work when using Dependency Injection, you have to use property or method injection in one of the two objects that are in the circular dependency. That’s because you cannot initialize both objects with each other; you have to create one first and then create the second object with the first object via initializer injection, then set a property on the first object with the second. Also, remember to avoid retain cycles by making one reference weak or unowned.
Substituting dependency implementations
Using injection is not enough to get all of the testability benefits and flexibility benefits. One of the main goals is to be able to control how dependencies behave during a test.
Say you have a dependency class that stores and retrieves data from a database. Injecting this database object, i.e., dependency, into a view controller does not give you control over how the database object behaves during a test. This is true because the view controller depends on a specific implementation of the database dependency that cannot be substituted at runtime. In order to control this dependency, the view controller should be able to accept a different implementation of the database object so that a fake implementation, which you control, can be injected during a test.
Therefore, injection alone does not enable substitutability. To enable substitutability, you need to define protocols for dependencies so the consumer can inject different classes that conform to the dependency’s protocol. When designing an object-under-construction, use protocol types for dependencies.
Recall that you can make dependency implementations substitutable at compile-time, at runtime or both. Each dependency is instantiated somewhere. In order to substitute a dependency’s implementation, you wrap the dependency’s instantiation with an if-else statement. In one condition you can instantiate a fake type for testing and, in another condition, you can instantiate a production type for running the app. Writing this if-else statement is different for compile-time substitution versus runtime substitution.
Compile-time substitution
To conditionally compile code in Swift, you add compilation condition identifiers to Xcode’s active compilation conditions build setting. Once you add custom identifiers to the active compilation condition’s build setting, you use the identifiers in #if and #elseif compilation directives.
You can use conditional compilation to change the dependency implementation that you want for a specific build configuration. For example, if you want to use a fake remote API implementation during tests:
- Create a Test build configuration.
- Change your target scheme’s Test scheme action’s build configuration to the Test build configuration created in the previous step.
- Add a
TESTidentifier to your target’s active compilation conditions build setting for the Test build configuration. - Find the line of code wherein the consumer is creating a real remote API instance.
- Write an
#if TESTcompilation directive and, under theifstatement, instantiate a fake remote API. - Write an
#elsecompilation directive and instantiate a real remote API under theelse. - Write an
#endifcompilation directive on the next line to close the conditional compilation block.
When you run the Test action in Xcode, to run unit and UI tests, the Swift compiler will compile the code that instantiates a fake remote API. When you run any other build action, such as Run, the Swift compiler will compile the code that instantiates a real remote API. Cool! Say goodbye to those flakey tests that try to make real network calls.
Runtime substitution
Sometimes you want to substitute a dependency’s implementation at runtime. For instance, if you want to run different logic for your beta testers who are using Testflight, you’ll need to use runtime substitution since the build that Testflight uses is the exact same build distributed to end users via the App Store. Therefore, you can’t use compile-time substitution for this situation. The Testflight use case is just one example.
To substitute an implementation at runtime, you write an if statement around the dependency instantiation. You need to decide where to get a value that you can use to compare in the if statement. For example, you can use a remote-feature flag service, or you can key off local values, such as the app’s version number.
Another neat trick is to use launch arguments to substitute dependencies at runtime. This is useful when you’re developing an app in Xcode. This is neat because you don’t need to recompile the app to change dependency implementations. Simply grab the launch arguments from UserDefaults and wrap your dependency instantiations with if statements that check launch argument values. You can use this trick during development or even during a continuous integration test.
OK — you’ve got the fundamentals. There are several approaches to putting Dependency Injection into practice. You’ll start learning the most basic approach and gradually move onto more difficult, real-world approaches. These are the Dependency Injection approaches you’ll learn in this chapter:
- On-demand: In this approach, you create dependency graphs when needed in a decentralized fashion. This approach is simple yet not very practical. You can use this approach to solidify your understanding of the fundamentals and to feel some of the pain addressed by more advanced approaches.
- Factories: Here, you begin to centralize initialization logic. This approach is also fairly simple and is designed to help you learn the fundamentals.
- Single container: This approach packages all the initialization logic together into one container. Since there’s state involved, it’s a bit more difficult to put into practice than the previous two approaches.
- Container hierarchy: One of the problems with centralizing all the initialization logic is you end up with one massive class. You can break a single container down into a hierarchy of containers. That’s what this approach is all about.
Alright, time to get started by jumping into the on-demand approach.
On-demand approach
This approach is designed for learning DI and for using DI in trivial situations. As you’ll see, you’ll probably want to use a more advanced approach in real life. In the on-demand approach, whenever a consumer needs a new object-under-construction, the consumer creates or finds the dependencies needed by the object-under-construction at the time the consumer instantiates the object-under-construction. In other words, the consumer is responsible for gathering all dependencies and is responsible for providing those dependencies to the object-under-construction via the initializer, a stored-property or a method.
Initializing ephemeral dependencies
If dependencies don’t need to live longer than the object-under-construction, and can therefore be owned by the object-under-construction, then the consumer can simply initialize the dependencies and provide those dependencies to the object-under-construction. These dependencies are ephemeral dependencies because they’re created and destroyed alongside the object-under-construction.
In this case, because the consumer is initializing all the dependencies, the consumer needs to know which concrete implementation to use when initializing a dependency. As long as the object-under-construction uses protocol types for its dependencies, the object-under-construction won’t know what concrete implementation the consumer used to create the dependencies, and that’s what you want.
Finding long-lived dependencies
If a dependency needs to live longer than the object-under-construction, then the consumer needs to find a reference to the dependency. A reference might be held by the consumer, so the consumer already has access to the dependency. Or a parent of the consumer might be holding on to a reference.
Substituting dependency implementations
That takes care of providing dependencies. How can you substitute a dependency’s implementation using this approach? Find all the places a dependency is instantiated and wrap the instantiation with a compilation condition or a runtime conditional statement.
These are the mechanics to the on-demand approach. What are the pros and cons?
Pros of the on-demand approach
-
This approach is relatively easy to explain and to understand.
-
Your code is testable because you can substitute nondeterministic side effect dependencies with deterministic fake implementations.
-
You can defer decisions. For example, you can use an in-memory data store implementation while you decide on a database technology. Changing from the in-memory implementation to the database implementation is easy because you can find all the in-memory instantiations and replace them with the database instantiations. This can be a bit tedious, so this is also a con that’s addressed in more advanced approaches.
-
Your team can work on the same feature at the same time because one developer can build an object-under-construction while another builds the dependencies. The developer building the object-under-construction can use fake implementations of the dependencies while the other developer builds the real implementations of the dependencies.
Cons of the on-demand approach
-
Dependency instantiations are decentralized. The same initialization logic can be duplicated many times.
-
Consumers need to know how to build the entire dependency graph for an object-under-construction. Dependencies can also have dependencies and so on. The consumer might have to instantiate a lot of dependencies. This is not ideal because multiple consumers using the same object-under-construction class will have to duplicate the dependency graph instantiation logic.
These cons can be addressed by taking a factories approach. You’ll learn this approach next.
Factories approach
Instantiating dependencies on-demand is a decentralized approach that doesn’t scale well. That’s because you’ll end up writing a lot of duplicate dependency instantiation logic as your dependency graph gets larger and more complex. The factories approach is all about centralizing dependency instantiation.
This approach works for ephemeral dependencies, i.e., dependencies that can be instantiated at the same time as the object-under-construction. This approach does not address managing long-lived dependencies such as singletons.
You’ll learn how to manage long-lived dependencies in the upcoming containers-approach section.
To take the factories approach, you create a factories class. What does a factories class look like?
Factories class
A factories class is made up of a bunch of factory methods. Some of the methods create dependencies and some of the methods create objects-under-construction. Also, a factories class has no state, i.e., the class should not have any stored properties.
One goal of creating a factories class is to make it possible for consumers to create objects-under-construction without having to know how to build dependency graphs required to instantiate objects-under-construction. This makes it super easy for any part of your code to get a hold of any object needed regardless of how much the object in question is broken down into smaller objects.
Next, you’ll learn how to design the different kinds of factory methods that make up a factories class.
Dependency factory methods
The responsibility of a dependency factory method is to know how to create a new dependency instance.
Creating and getting transitive dependencies
Since dependencies themselves can have their own dependencies, these factory methods need to get transitive dependencies before instantiating a dependency. Transitive dependencies might be ephemeral or long-lived.
To create an ephemeral transitive dependency, a dependency factory method can simply call another dependency factory included in the factories class.
To get a reference to a long-lived transitive dependency, a dependency factory method should include a parameter for the transitive dependency. By adding parameters, long-lived transitive dependencies can be provided to the dependency factory method.
Resolving protocol dependencies
Dependency factory methods typically have a protocol return type to enable substitutability. When this is true, dependency factory methods encapsulate the mapping between protocol and concrete types.
This is typically called resolution because a dependency factory method is resolving which implementation to create for a particular protocol dependency. In other words, these methods know which concrete initializer to use.
To illustrate, say you have a UserProfileDataStore protocol. Say this protocol is a dependency. The factories class encapsulates the logic that knows to use a DatabaseUserProfileDataStore for objects-under-construction that need a UserProfileDataStore. You would place this logic into a single factory method inside the factories class.
This centralizes the dependency resolution so that you only have once place in your codebase that knows how to resolve the UserProfileDataStore dependency. This is awesome because you can change what kind of data store your entire app uses by changing one line of code.
Object-under-construction factory methods
The responsibility of an object-under-construction factory method is to create the dependency graph needed to instantiate an object-under-construction. Object-under-construction factory methods look just like dependency factory methods. The only difference is object-under-construction factory methods are called from the outside of a factories class, whereas dependency factory methods are called within a factories class.
Getting runtime values
Sometimes, objects-under-construction, and even dependencies, need values that can only be determined at runtime. For example, a REST client might need a user ID to function. These runtime values are typically called runtime factory arguments. As the name suggests, you handle this situation by adding a parameter, for each runtime value, to the object-under-construction’s or dependency’s factory method. At runtime, the factory method caller will need to provide the required values as arguments.
Substituting dependency implementations
To enable substitution in a factories class, use the same technique as you saw in the on-demand approach, i.e., wrap dependency resolutions with a conditional statement. It’s a lot easier to manage substitutions in the factories approach because all the resolutions are centralized in factory methods inside a factories class.
This means you don’t have to duplicate conditional statements inside every consumer; you write the conditional statement once, and only once, for each dependency resolution. This is a big win.
Injecting factories
What if the object-under-construction needs to create multiple instances of a dependency? What if the object-under-construction is a view controller that needs to create a dependency every time a user presses a button or types a character into a text field?
Factory methods return a single instance of a dependency — so, Houston, we have a problem. The trick is to find a way to give the object-under-construction the power to invoke a factory method multiple times, whenever the object-under-construction needs to create a new dependency instance.
Your first instinct might be to simply create an instance of the factories class within the object-under-construction.
The object-under-construction would then have access to every single factory method. While this is a very simple approach, the problem is that the object-under-construction becomes harder to unit test. That’s because all dependencies are no longer injected from the outside. With this approach, you’d need to work with the factories class in order to substitute real implementations with fake implementations.
The goal is to be able to unit test an object-under-construction without needing the factories class at all. Therefore, it’s important to give objects-under-construction the ability to create multiple instances of dependencies from the outside.
You can give this power to objects-under-construction from the outside using one of two Swift features: closures or protocols.
Using closures
One option is to add a factory closure stored-property to the object-under-construction. Here are the steps:
- Declare a stored-property in the object-under-construction with a signature such as:
let makeUseCase: () -> UseCase. - Add an initializer parameter to the object-under-construction with the same closure type.
- Go to the factories class and find the factory method that creates the object-under-construction.
- Use initializer injection, in the object-under-construction factory method, to inject a closure that creates a new dependency. To do this, open a closure in the object-under-construction’s initializer call. Inside the closure, call the dependency factory method for the dependency in question and return the new instance. The closure captures the factories class instance so the object-under-construction essentially holds on to the factories object without knowing.
- Now, the object-under-construction can easily create a new instance of a dependency by invoking the factory closure, whenever.
This is so cool because the object-under-construction can create as many instances without needing to know all the transitive dependencies behind the dependency created in the factory closure. This means you can change the entire dependency structure without having to change a single line of code in the object-under-construction.
That’s one option; the other option is to declare a factory protocol.
Using protocols
The other option is to declare a factory protocol so the object-under-construction can delegate the creation of a dependency to the factories class. Here are the steps:
- Declare a new factory protocol that contains a single method for the dependency that the object-under-construction needs to create.
- The factories class will already conform to this protocol because the dependency factory method in the protocol should match the implemented factory method in the factories class. Simply declare conformance in the factories class.
- Add a stored-property and initializer parameter of the factory protocol type to the object-under-construction. This allows you to inject the factories object into the object-under-construction; however, the object-under-construction will only see the single factory method defined in the protocol. The object-under-construction does not know it is injected with the factories object because the protocol restricts the object-under-construction’s view.
- Go into the object-under-construction’s factory method in the factories class and update the initialization line to inject
self.selfis the factories object which conforms to the new factory protocol you declared.
The object-under-construction now has the power to create new dependency instances whenever, while not having access to all the factories in the factories class. You get the same benefits with this approach as the closure approach. This decision comes down to style preference.
That’s all there is to injecting factories. It’s time to take a quick look at when and how to create instances of the factories class.
Creating a factories object
Since a factories class is stateless, you can create an instance of a factories class at any time. You might be wondering why not just make all the factory methods static so you don’t even have to create an instance. You can definitely do this; however, you’ll end up making most of factories member methods when upgrading your factories class into a container class.
You’ll learn about this next, after checking out the pros and cons to this factories approach.
Pros of the factories approach
-
Ephemeral dependencies are created in a central place. This gives you a lot of power to switch out entire subsystems by changing a couple of lines of code.
-
Substituting a large amount of dependencies during a functional UI test is much easier because all your dependencies are initialized in one class. Developers typically want to fake out the entire networking and persistence stack during UI tests because developers want deterministic tests so their builds don’t constantly break with false positives.
-
Consumers are more resilient to change because they no longer need to know how to build dependency graphs. That’s one less responsibility for all consumers. This helps your team work in parallel because code is more loosely coupled.
-
Code is generally easier to read because all of the initialization boilerplate is moved out of the classes that do interesting work.
Cons of the factories approach
-
In a large app, a single factories class can become extremely large. You can break up large factories classes into multiple classes.
-
This approach only works for ephemeral objects. Longer-lived objects need to be held somewhere. Ideally, all dependencies should be centrally managed regardless of lifespan. You’ll learn how to do this in the next section.
In practice, a factories class is not enough. You’ll most likely need to convert the factories class into a container class.
This factories section is here in order to help you take small steps because there’s so much to learn about DI.
When refactoring a codebase to use DI, feel free to take this factories approach to get a feel for the pattern. As you’ll see in the next section, you can easily update your code to go from this factories approach to the container approach.
Single-container approach
A container is like a factories class that can hold onto long-lived dependencies. A container is a stateful version of a factories class.
What are some examples of long-lived dependencies? A data store is a perfect example. A data store is a container for data that is needed to render screens. Since this data can probably change, you want a single copy of this data. Therefore, you don’t want to create a new data store instance every time an object needs a data store. You probably want a single instance to live as long as the app’s process, i.e., you need a singleton. To keep a data store instance alive, you need an object to hold onto this singleton so that ARC doesn’t de-allocate the data store.
Keep reading to see how to design a container class.
Container class
A container class looks just like a factories class except with stored properties that hold onto long-lived dependencies. You can either initialize constant stored properties during the container’s initialization or you can create the properties lazily if the properties use a lot of resources. However, lazy properties have to be variables so constant properties are better by default.
Having long-lived dependencies co-located with factories changes how factories access these long-lived dependencies. You’ll soon explore this more.
Dependency factory methods
Recall that the responsibility of a dependency factory method is to know how to create a new dependency instance. Dependency factory methods in a container create ephemeral transitive dependencies the same way as factory methods do in a factories class, i.e., by calling another dependency factory.
How dependency factory methods get ahold of long-lived dependencies in a container, though, is different than in a factories class.
To get a reference to a long-lived transitive dependency, a dependency factory method gets the dependency from a stored property. This is nice because it removes the need to add parameters to factory methods.
All factory methods can be invoked without any inputs. The fact that these methods can have zero parameters is super powerful. You take a dependency with a complex initializer such as init(remoteAPI: UserRemoteAPI, dataStore: UserDataStore) and reduce it down to a factory method, such as makeProfileViewModel().
The above is true except for runtime value parameters. Since runtime values are provided outside of the container, and because runtime values are not long-lived dependencies, factory methods in a container are still much easier to invoke. As you’ll see later, this comes in handy when injecting factories.
Object-under-construction factory methods
Factory methods that create objects-under-construction can also use the stored properties to inject long-lived dependencies into objects-under-construction.
Just like dependency factory methods from above, these factory methods also don’t need to have parameters for long-lived dependencies. This is a huge benefit for consumers because consumers don’t have to manage anything in order to create objects-under-construction. They simply invoke the empty argument factory method or provide runtime values.
Consumers can now create objects-under-construction without having to know anything about the dependency graphs behind these objects. This gives your code flexibility because one developer can change the dependency graph without affecting the developer building the code around the consumer.
That takes care of linking a container’s stored properties to factory method implementations. Next, you’ll see how to substitute implementations of the long-lived dependencies held by stored properties.
Substituting long-lived dependency implementations
You can substitute implementations of long-lived dependencies by wrapping their initialization line with a conditional statement. This is possible as long as the long-lived stored properties use a protocol type. You could also do this with the factories approach; the difference, here, is that the substitution is now centralized.
Easy peasy. At this point, you probably want to know when and how to create a container.
Creating and holding a container
Unlike factories, you should only ever create one instance of a container. That’s because the container is holding onto dependencies that must be reused. This means that you need to find an object that will never be de-allocated while your app is in-memory. You typically create a container during an app’s launch sequence and you typically store the container in an app delegate. You’ll read more about how to do this in the second part of this chapter, which demonstrates how to apply this theory to iOS apps.
Going from learning how to build a factories class to learning how to build a single container is not a huge leap. However, the theory behind containers gets interesting when you need to break a single container into a container hierarchy. You’ll learn about this next, after going through the pros and cons of the single-container approach.
Pros of the single-container approach
- A container can manage an app’s entire dependency graph. This removes the need for other code to know how to build object graphs.
- Containers manage singletons; therefore, you won’t have singleton references floating in global space. Singletons can now be managed centrally by a container.
- You can change an object’s dependency graph without having to change code outside the container class.
Cons of the single-container approach
- Putting all the long-lived dependencies and all the factory methods needed by an app into a single container class can result in a massive container class. This is the most common issue when using DI. The good news is that you can break this massive container up into smaller containers.
Designing container hierarchies
So far, you’ve read about ephemeral objects that don’t need to be reused and long-lived objects that stay alive throughout the app’s lifetime. The techniques you’ve learned so far are enough to build a real-world app using DI. Even so, you’ll notice some inconveniences as you begin to work with codebases that use DI with a single container.
Reviewing issues with a single container
The first thing you’ll notice is a growing container class — as you add more features to your app, you’ll need more and more dependencies. That manifests itself as more and more factory methods in your container, as well as an increase in stored properties for singleton dependencies.
You’ll also notice a lot of optional conditional unwrapping. Most apps have many dependencies that need to know about the currently signed-in user to do things like authenticate HTTP requests.
If all the reusable dependencies live as long as an app lives, the container logic will need to handle optional cases because the user can be signed out while the app is running.
Based on the container design thus far, there’s nothing stopping any consumer from asking the container for dependencies that require the user to be signed in.
Ideally, consumers would only have access to these reusable dependencies when a user is signed in. This is just one of many examples of optional case handling that sneaks into your singe-dependency container.
In this section, you’ll learn how to use advanced DI techniques address these undesirable qualities.
Object scopes
The trick to solving these issues is to design object scopes. To do this, think about at what point in time dependencies should be created and destroyed. Every object has a lifetime. You want to explicitly design when objects come and go. For example, objects in a user scope are created when a user signs in and are destroyed when a user signs out. Objects in a view controller scope are created when the view controller loads and are destroyed when the view controller is de-allocated.
Here are the typical scopes you find in most apps:
-
App scope: Traditional singletons fall under this scope. Objects in the app scope are created when the app launches and are destroyed when the app is killed. Typical dependencies you find in this scope include authentication stores, analytics trackers, logging systems, etc.
-
User scope: User scope objects are created when a user signs in, and they’re destroyed when a user signs out. Some apps allow users to sign in to multiple accounts. In this case, the app could have multiple user scopes alive at the same time. Most dependencies, such as remote API’s and data stores, are usually found in this scope. This scope also typically contains more specific versions of dependencies found in the app scope. For instance, the app scope could have an anonymous analytics tracker while a user scope could have a user specific analytics tracker.
Scopes are very powerful because they help convert a bunch of mutable state into immutable state. For that reason, you can go even further with scopes by designing shorter lived scopes. Here are a couple of examples.
-
Feature scope: Objects in a feature scope are created when the user navigates to a feature and are destroyed when the user navigates away. Feature scopes are handy when a feature needs to share data amongst many objects that make up the feature.
For example, in Koober, the pick-me-up feature needs to know the user’s current location. The user’s current location is fetched once and then is not retrieved again; the current location is immutable from the pick-me-up feature’s point of view.
Many different view controllers and objects with business logic need to utilize the current location value in order to function.
Imagine having to pass this value around from object to object. By creating a feature scope, the current location can be injected into all of these objects.
The objects don’t need to worry about how to get the value. As far as the objects in the feature are concerned, the location value is immutable even though the user can still ask for a new ride, and a new current location will be fetched. This works because, every time a user starts a new ride, an entirely new object graph is created with the current static location value.
- Interaction scope: Objects in an interaction scope are created when a gesture is recognized and are destroyed when the gesture ends. This is handy when you are building a complex user interaction. This is an example of a very short-lived scope.
Once you’ve designed the scopes that you need, and once you’ve identified which dependencies should live in which scopes, the next step is to break up the single container into a container hierarchy.
Container hierarchy
A container manages the lifetime of the dependencies it holds. Because of this, each scope maps to a container. A user scope would have a user-scoped container. The user-scoped container is created when the user signs in and so forth. This is how the dependencies that are in the user scope are all created and destroyed at the same time, because the scoped container owns these objects.
For every scope you design, you create a container class. When you do this, you’ll notice that scoped containers will want to have access to factory methods and stored properties from other containers. To do this, you build a container hierarchy.
Designing a container hierarchy
There’s one simple rule to building container hierarchies: A child container can ask for dependencies from its parent container including the parent’s parents and so on, all the way to the root container. A parent container cannot ask for a dependency from a child container.
The app scoped container is always the root container. If you think about how the hierarchy maps to length of object lifetimes, the rule makes a lot of sense. Parent containers live longer than child containers. If the parent was allowed to ask for a dependency from a child container, the child container might no longer be alive. So that’s the rationale for the rule.
Are you ready for some meta?
The container hierarchy is an object graph itself; therefore, you can use initializer injection to provide child containers with parent containers.
As with all DI conventions, this sounds more complicated than it really is. The child container’s initializer needs to have a parameter for the parent container. The child container can then hold a reference to the parent container in a stored-property. This gives the child access to all the factory methods and stored properties in the parent container.
As an example, say you have a UserProfileViewModel. This view model needs a Logger in order to log events. Logger needs to live as long as the app is alive because you want to be able to log messages regardless of whether or not a user is signed in. So the logger goes into an AppDependencyContainer.
The UserProfileViewModel, however, is specific to a signed-in user, so this object is scoped to the signed-in user. The view model goes into a UserDependencyContainer. The view model needs the logger but the logger lives in a different container.
To solve this, you add a AppDependencyContainer parameter to UserDependencyContainer’s initializer. That way, UserDependencyContainer, the child container, can ask AppDependencyContainer, the parent container, for a logger when initializing a new UserProfileViewModel.
Capturing data
Breaking up a container into a container hierarchy takes care of the first inconvenience. What about the second inconvenience — the one about handling optionals?
Besides managing the lifetime of dependencies, a container can also capture data model values. This is helpful if the data model value is immutable for the lifetime of the container. Capturing data in a container is a way to convert mutable values into immutable values. This makes the code inside a container more deterministic because the logic does not have to consider a change in the captured value.
To illustrate, say you have an app-scoped container named AppContainer. AppContainer has a UserSessionDataStore that contains a user session only if a user is signed in. Say you have a user-scoped container named UserContainer. UserContainer is initialized with an AppContainer and the currently signed-in user session object — not the data store, but the actual session.
This is important because a user container cannot exist without a user session. This takes away the optional case handling related to signed-in user.
Moving forward with the example, inside UserContainer, say you have a factory method for creating a UserProfileRemoteAPI. The remote API needs the user session in order to function. That’s easy — the remote API factory method can access the user session stored-property.
Remember the factory and the stored-property are both inside the same UserContainer class. The days of having to check if there’s a signed-in user all over a codebase are gone!
Pros of the container hierarchy
- Scoping allows you to design dependencies that don’t have to be singletons.
- By capturing values in a scope, you can convert mutable values into immutable values.
- Container classes are shorter when you divide container classes into scoped container classes.
Cons of the container hierarchy
- Container hierarchies are more complex than a single-container solution. Developers that join your team might encounter a learning curve.
- Even when containers are broken up into scoped containers, complex apps might still end up with really long container classes.
By this point, you’ve learned a lot about DI, and you’re on your way to mastering object-oriented design. This is all the theory you’ll need to understand how Koober, the example app, uses DI. In this book, you’ll encounter different versions of Koober that are built using different architectures. DI is such a universal approach that every version of Koober you’ll see uses the same DI pattern as DI can support all kinds of different architectures.
Most of the theory you’ve read is applicable to iOS without any special considerations. However, there are a couple of iOS-specific decisions that you’ll need make when using DI in your iOS codebases. It’s time to go from theory to practice.
Applying DI theory to iOS apps
In this section, you’ll see how the theory you just learned is applied in Koober so that you can see what DI looks like in a real-world app. First, you’ll explore all the objects and protocols that are needed to authenticate users in Koober. Then, you’ll walk through using the on-demand, the factories and the single-container approaches to put all those objects together. Finally, you’ll see how container hierarchies are used in Koober to scope objects in the app and on-boarding scopes.
Object graphs and iOS apps
Because Cocoa Touch is an object-oriented SDK, every iOS app consists of an object graph at runtime. An instance of UIApplication is the root of an app’s object graph. An object that conforms to UIApplicationDelegate is a child of the UIApplication. Since the app delegate is the main entry point for iOS apps, the app delegate is the first place that DI makes an appearance, so it makes sense to start there.
One of the first objects you typically instantiate is a root view controller. Koober’s root view controller is the first object-under-construction example that you’ll explore. The root view controller is the root of the object graph that you design when building iOS apps. The ultimate goal is to learn how to use DI containers to construct this object graph. In the rest of this chapter, you’ll work towards this goal.
To set the stage, the following section walks you through the object graph required to authenticate users in Koober.
Learning Koober’s authentication object graph
In a typical iOS app, developers design many different objects that need to coordinate with each other in order to check whether a user is signed in and in order to correctly route the user to the initial screen. Here are the objects and protocols Koober uses to authenticate users:
UserSessionRepository’s dependency graph
Here are all the protocols and objects needed to create a KooberUserSessionRepository:
-
AuthRemoteAPI: The
AuthRemoteAPIprotocol represents the networking layer of Koober’s user authentication system. Implementations of this protocol are responsible for talking to Koober’s cloud services to sign in existing users and sign up new users. In exchange for a successful authentication attempt, Koober Cloud returns a token that should be used for making authenticated HTTP requests.The example code uses
FakeAuthRemoteAPIso you don’t need to have a network connection or a local server to use Koober. All implementations ofAuthRemoteAPIdo not depend on any other objects. -
UserSessionCoding: The object that implements this
UserSessionCodingprotocol is responsible for encoding aUserSessionobject intoDataand for decodingDatainto aUserSessionobject. Koober’sKeychainUserSessionDataStoreuses this for storing aUserSessionasDatain the keychain. -
UserSessionDataStore: Implementations of
UserSessionDataStoreare responsible for storing a user session for the signed-in user. Koober includes many different implementations of this protocol.For example, you can use
FileUserSessionDataStoreduring development to be able to sign out the current user by deleting the app in the simulator.KeychainUserSessionDataStoreis designed to be used in an app store build so that Koober can store real user credentials in the Keychain.KeychainUserSessionDataStoredepends on a user session coder that conforms toUserSessionCoding. -
UserSessionRepository: This repository is a create, read, update and delete protocol for managing user sessions. It’s used to determine whether or not a user is signed in when Koober launches, it’s used to sign in an existing user and it’s used to sign up a new user.
KooberUserSessionRepositoryimplements this protocol and is the default implementation used in Koober.KooberUserSessionRepositoryis stateful, this object must be a long-lived dependency that lives as long as the app.
This is KooberUserSessionRepository’s dependency graph:
Here’s what KooberUserSessionRepository’s dependency graph looks like once UserSessionDataStore is resolved:
Finally, this is what KooberUserSessionRepository’s fully materialized dependency graph looks like:
LaunchViewController’s dependency graph
These are all the protocols and objects needed to create a LaunchViewController:
-
NotSignedInResponder:
NotSignedInResponderis a user authentication protocol. Objects in Koober call into this protocol when they determine that a user is not signed in.
This can happen during launch or when a user signs out. The object that implements this protocol is responsible for navigating the user to the OnboardingViewController. MainViewModel implements this protocol.
-
SignedInResponder:
SignedInResponderis also a user authentication protocol. This protocol is used when objects in Koober determine that a user is signed in.This can occur on launch or after a user successfully signs in or signs up.
MainViewModelimplements this protocol. -
LaunchViewModel: This view model holds UI state for a
LaunchViewController. This object would normally be a long-lived dependency but, in Koober, it is ephemeral because Koober only ever creates oneLaunchViewControllerbecause apps only cold launch one time in a process lifetime. -
LaunchViewController: When Koober launches for the first time, Koober needs to start up all the subsystems and needs to determine if a user is signed in. While Koober is launching,
LaunchViewControllerbegins looking for a signed-in user and presents a splash screen.LaunchViewControllerdepends on aLaunchViewModelin order to begin searching for a signed-in user.This is
LaunchViewController’s dependency graph:
Here’s what LaunchViewController’s fully materialized dependency graph looks like:
OnboardingViewController’s dependency graph
OnboardingViewController depends on the following protocols and objects:
-
OnboardingViewModel: This holds UI state for an
OnboardingViewController. This object is a long-lived dependency that lives while the user is signed out. -
GoToSignUpNavigator:
GoToSignUpNavigatoris a UI navigation protocol. The implementor is responsible for taking the user to the sign-up screen.OnboardingViewModelimplements this protocol. -
GoToSignInNavigator:
GoToSignInNavigatoris a UI navigation protocol. The implementor is responsible for taking the user to the sign-in screen.OnboardingViewModelimplements this protocol. -
WelcomeViewModel: This view model holds UI state for a
WelcomeViewController. -
WelcomeViewController: This view controller renders the welcome screen where a user can either go to the sign-up screen or the sign-in screen.
-
SignInViewModel: This holds UI state for a
SignInViewController. -
SignInViewController: Users sign in to Koober using this view controller.
-
SignUpViewModel: This view model holds UI state for a
SignUpViewController. -
SignUpViewController: Users create a new Koober account using this view controller.
-
OnboardingViewController: If a user is not signed in, Koober’s
MainViewControllerpresents anOnboardingViewController.OnboardingViewControlleris a container view controller that is responsible for managing the navigation between the welcome screen and the sign-in and sign-up screens. This controller should only be alive as long as a user is not signed in.
This is OnboardingViewController’s dependency graph:
Here’s what OnboardingViewController’s fully materialized dependency graph looks like:
MainViewController’s dependency graph
Finally, here are the protocols and objects MainViewController depends on:
-
MainViewModel: The
MainViewModelholds UI state for aMainViewController. This object is stateful; therefore, it needs to be a long lived dependency. -
MainViewController:
MainViewControlleris Koober’s root view controller.MainViewControlleris a container view controller that manages top-level navigation.To illustrate,
MainViewControllerpresents and dismisses the launch screen, the on-boarding screens and the signed-in screens.MainViewControllerdepends on view controller factory methods in order to createLaunchViewControllers,OnboardingViewControllers andSignedInViewControllers.
Here’s what MainViewController’s fully materialized dependency graph looks like:
Now that you’re familiar with Koober’s top-level object graph, you’ll see how to use the on-demand approach to build this graph.
Applying the on-demand approach
MainViewController is Koober’s root view controller’s class. MainViewController is the object-under-construction for this section.
Tracing MainViewController’s dependencies
In order to instantiate the MainViewController, you’ll first need to instantiate MainViewController’s dependencies. Here’s MainViewController’s initializer’s method signature. The real initializer in Koober is a bit more complex; this is a simplified version to demonstrate the on-demand DI approach:
public init(viewModel: MainViewModel,
launchViewController: LaunchViewController)
MainViewController has two dependencies, a MainViewModel and a LaunchViewController. Creating a MainViewModel is easy:
let mainViewModel = MainViewModel()
However, creating a LaunchViewController is more complicated because LaunchViewController has its own dependency graph. The objects of this graph are considered MainViewController’s transitive dependencies.
Here’s LaunchViewController’s initializer’s method signature.
public init(viewModel: LaunchViewModel)
To create a LaunchViewController you need to first create a LaunchViewModel using LaunchViewModel’s initializer:
public init(userSessionRepository: UserSessionRepository,
notSignedInResponder: NotSignedInResponder,
signedInResponder: SignedInResponder)
LaunchViewModel has three dependencies, a UserSessionRepository, a NotSignedInResponder and a SignedInResponder.
As you can see, decomposing large objects into single-responsibility objects results in deep object graphs. For this reason, you’ll find the on-demand approach is not practical for real-world apps that have large and deep object graphs.
The on-demand approach is good for teaching DI and for using Dependency Injection in small apps. Nevertheless, it’s worth taking a look at how to build MainViewController’s dependency graph using the on-demand approach as a stepping stone to learning the factories approach.
Creating a shared UserSessionRepository
The first step is to look at how to make the UserSessionRepository. Remember the main objective is to create a MainViewController. Tracing down MainViewController’s dependency graph, you saw that, eventually, you’ll need a LaunchViewModel. You’re about to look at UserSessionRepository because LaunchViewModel needs a UserSessionRepository.
UserSessionRepository is a protocol, so you need to resolve to an implementation. Koober uses a default implementation named KooberUserSessionRepository. KooberUserSessionRepository is stateful; therefore, a new instance should not be instantiated when another object needs this dependency. You need to create this object once and hold it so that all objects-under-construction can use the same KooberUserSessionRepository instance.
A free global constant is a good place to hold this object since it needs to live as long as the app is running. Here’s how to set this up:
// This code is global, it’s not in any type.
public let GlobalUserSessionRepository:
UserSessionRepository = {
let userSessionCoder =
UserSessionPropertyListCoder()
let userSessionDataStore =
KeychainUserSessionDataStore(
userSessionCoder: userSessionCoder)
let authRemoteAPI =
FakeAuthRemoteAPI()
return KooberUserSessionRepository(
dataStore: userSessionDataStore,
remoteAPI: authRemoteAPI)
}()
Even though KooberUserSessionRepository has a relatively simple dependency graph, a lot of code is required to build its dependency graph.
If KooberUserSessionRepository could be instantiated multiple times, you would have to duplicate all this code every time you needed a new KooberUserSessionRepository when using the on-demand approach. This duplication is the main downside to the on-demand approach and is the reason this approach is not practical in real life.
Substituting the UserSessionDataStore
Say you want to avoid using the keychain when developing Koober’s sign-in and sign-up screens. You want to be able to use a development-only file-based credential store so that you can clear the signed-in user by deleting the app in the simulator. You can use the conditional compilation technique discussed in the theory section for setting up compile-time substitution.
Here’s an example demonstrating how to do this by updating the previous code example with conditional compilation:
// This code is global, it’s not in any type.
public let GlobalUserSessionRepository:
UserSessionRepository = {
#if USER_SESSION_DATASTORE_FILEBASED
let userSessionDataStore =
FileUserSessionDataStore()
#else
let userSessionCoder =
UserSessionPropertyListCoder()
let userSessionDataStore =
KeychainUserSessionDataStore(
userSessionCoder: userSessionCoder)
#endif
let authRemoteAPI =
FakeAuthRemoteAPI()
return KooberUserSessionRepository(
dataStore: userSessionDataStore,
remoteAPI: authRemoteAPI)
}()
This example switches which UserSessionDataStore is initialized based on the USER_SESSION_DATASTORE_FILEBASED identifier. If the current scheme’s active compilation conditions build setting includes this identifier, the compiler will compile the code that initializes a FileUserSessionDataStore. Otherwise, the code that initializes a KeychainUserSessionDataStore is compiled.
Same as before, if you could create more than one UserSessionDataStore, you would have to duplicate the conditional compilation if you want to use the same UserSessionDataStore implementation across your codebase. This is inconvenient and undesirable. Substitution is much more powerful when used alongside the factories and containers approach.
That wraps up creating the UserSessionDataStore. The example will use this code to create a MainViewController in the next section.
Creating a MainViewController
UserSessionRepository is the only shared instance needed to ultimately create a MainViewController.
Now that you’ve seen how to set up a shared instance dependency, it’s time to go into application(_:didFinishLaunchingWithOptions:) to see how the MainViewController is created and installed:
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let mainViewModel = MainViewModel()
let launchViewModel =
LaunchViewModel(
userSessionRepository: GlobalUserSessionRepository,
notSignedInResponder: mainViewModel,
signedInResponder: mainViewModel)
let launchViewController =
LaunchViewController(viewModel: launchViewModel)
let mainViewController =
MainViewController(
viewModel: mainViewModel,
launchViewController: launchViewController)
window.frame = UIScreen.main.bounds
window.makeKeyAndVisible()
window.rootViewController = mainViewController
return true
}
Notice how the GlobalUserSessionRepository shared instance is used to create a LaunchViewModel. All the other dependencies in this example are created inside application(_:didFinishLaunchingWithOptions:). Voila — the MainViewController is finally instantiated towards the end before being installed as the root view controller.
So far, you’ve seen how to use the on-demand approach to build the root view controller’s object graph. The app delegate isn’t the only place an app needs to create new objects. Next, you’ll visit the MainViewController’s implementation to see how the on-demand approach works when a parent view controller needs to create a new instance of a child view controller.
Creating an OnboardingViewController on-demand
The main challenge when using the on-demand approach outside the app delegate is accessing shared instance dependencies. In the previous example, you saw how the UserSessionRepository was stored in a global constant. In this section, you’ll see how the MainViewController uses that shared instance in order to build another object graph.
Using global references in this way is not ideal. Later in this chapter, you’ll see how to use a container instead of global references to store shared instances.
The following example shows how MainViewController creates an OnboardingViewController. During a cold start, if the LaunchViewController has determined a user is not signed in, the MainViewController will instantiate an OnboardingViewController.
Because OnboardingViewController depends on a graph of objects, MainViewController needs to create all of OnboardingViewController’s dependencies.
The following method, is from MainViewController’s implementation and shows how the OnboardingViewController is created:
public func presentOnboarding() {
let onboardingViewModel = OnboardingViewModel()
let welcomeViewModel =
WelcomeViewModel(goToSignUpNavigator: onboardingViewModel,
goToSignInNavigator: onboardingViewModel)
let welcomeViewController =
WelcomeViewController(viewModel: welcomeViewModel)
let signInViewModel =
SignInViewModel(
userSessionRepository: GlobalUserSessionRepository,
signedInResponder: self.viewModel)
let signInViewController =
SignInViewController(viewModel: signInViewModel)
let signUpViewModel =
SignUpViewModel(
userSessionRepository: GlobalUserSessionRepository,
signedInResponder: self.viewModel)
let signUpViewController =
SignUpViewController(viewModel: signUpViewModel)
let onboardingViewController =
OnboardingViewController(
viewModel: onboardingViewModel,
welcomeViewController: welcomeViewController,
signInViewController: signInViewController,
signUpViewController: signUpViewController)
onboardingViewController.modalPresentationStyle = .fullScreen
present(onboardingViewController, animated: true) { ... }
self.onboardingViewController = onboardingViewController
}
The method above is fairly self explanatory. It creates and presents an OnboardingViewController. By the way, that’s one long method! Herein lies the problem with the on-demand approach. If you use the on-demand approach in a complex app, you’ll find long methods like this all over the place. This is better than nothing because your objects are now testable. However, as you saw in the theory section, there’s a better way.
Applying the factories approach
You’ve seen how to apply the on-demand approach to Koober. You saw how object graphs are assembled all over the place. Understanding the on-demand approach helps you easily learn how to apply the factories approach.
In this section, you’ll learn how to create the same objects from the last section using a factories class named KooberObjectFactories.
You’ll begin by looking at the methods needed to create a UserSessionRepository. Then, you’ll see how KooberObjectFactories is used to create a shared global UserSessionRepository. From there, you’ll walk through the methods needed to create a MainViewController.
You’ll see how to give MainViewController the power to create OnboardingViewControllers by injecting a factory closure. You’ll wrap up this example by learning how KooberObjectFactories is used within Koober’s app delegate and by taking a look at how MainViewController invokes the factory closure when it needs to create a new OnboardingViewController.
Creating a shared UserSessionRepository
The following code example demonstrates how to build a simple factories class that can create a UserSessionRepository and all the objects in UserSessionRepository’s dependency graph:
class KooberObjectFactories {
// Factories needed to create a UserSessionRepository.
func makeUserSessionRepository() -> UserSessionRepository {
let dataStore = makeUserSessionDataStore()
let remoteAPI = makeAuthRemoteAPI()
return KooberUserSessionRepository(dataStore: dataStore,
remoteAPI: remoteAPI)
}
func makeUserSessionDataStore() -> UserSessionDataStore {
#if USER_SESSION_DATASTORE_FILEBASED
return FileUserSessionDataStore()
#else
let coder = makeUserSessionCoder()
return KeychainUserSessionDataStore(userSessionCoder: coder)
#endif
}
func makeUserSessionCoder() -> UserSessionCoding {
return UserSessionPropertyListCoder()
}
func makeAuthRemoteAPI() -> AuthRemoteAPI {
return FakeAuthRemoteAPI()
}
This example takes all the code that was in the GlobalUserSessionRepository’s declaration from the previous on-demand approach example and distributes object initializations into factory methods, one for each dependency.
One nice thing about factory methods is that they can hide implementation substitutions. For example, look at makeUserSessionDataStore() in the above code. The caller of this method has no idea they may get a FileUserSessionDataStore or a KeychainUserSessionDataStore.
This is great because it gives you the flexibility to change which data store to use by changing one method without needing to change any of the calling code.
Now the factories class is set up, take a look below at how GlobalUserSessionRepository is declared:
// This code is global, it’s not in any type.
public let GlobalUserSessionRepository:
UserSessionRepository = {
let objectFactories =
KooberObjectFactories()
let userSessionRepository =
objectFactories.makeUserSessionRepository()
return userSessionRepository
}()
This is a lot less code than the same declaration you saw in the on-demand approach example. The factories approach moves a ton of boilerplate code away from object usage sites into the centralized factories class. This helps you and other developers read code because you don’t have to reason about how object graphs are assembled. If you need to see how an object is constructed, the factories class is always a Command-click away.
Alright, that’s how the global shared UserSessionRepository is created using the factories approach. Next, you’ll see how this code is used to create a MainViewController.
Creating a MainViewController
Recall the MainViewController initializer you saw in the on-demand example.
public init(viewModel: MainViewModel,
launchViewController: LaunchViewController)
This is a simplified initializer; the one in Koober is more complex. The initializer used in Koober needs a couple of factory closures because MainViewController needs to be able to create view controllers after it’s created.
Here’s the initializer used in Koober:
init(viewModel: MainViewModel,
launchViewController: LaunchViewController,
// Closure that creates an OnboardingViewController
onboardingViewControllerFactory:
@escaping () -> OnboardingViewController,
// Closure that creates a SignedInViewController
signedInViewControllerFactory:
@escaping (UserSession) -> SignedInViewController)
Considering that this adds quite a bit of complexity, you’ll first walk through a factories example that uses the same simple initializer from the on-demand approach, and then you’ll explore the code necessary to use the more complex initializer.
Since MainViewController needs a MainViewModel, you’ll first look at how the factories approach creates a MainViewModel. The following code adds a MainViewModel factory method to KooberObjectFactories:
class KooberObjectFactories {
// Factories needed to create a UserSessionRepository.
...
// Factories needed to create a MainViewController.
func makeMainViewModel() -> MainViewModel {
return MainViewModel()
}
}
There’s not much to say about this code; it adds a simple factory method. You’ll later see where it’s used.
Since MainViewModel is stateful, the factory setup should only create one MainViewModel instance. For this reason, you need a global constant such as the one below:
// This code is global, it’s not in any type.
public let GlobalMainViewModel: MainViewModel = {
let objectFactories = KooberObjectFactories()
let mainViewModel = objectFactories.makeMainViewModel()
return mainViewModel
}()
This example is also pretty straightforward. Putting a shared MainViewModel into a global constant gives KooberObjectFactories access to this shared instance.
Now that KooberObjectFactories can create and access a shared MainViewModel, it’s time to give KooberObjectFactories the ability to create a MainViewController:
class KooberObjectFactories {
// Factories needed to create a UserSessionRepository.
...
// Factories needed to create a MainViewController.
func makeMainViewModel() -> MainViewModel {
return MainViewModel()
}
// New code starts here.
// 1
func makeMainViewController(
viewModel: MainViewModel,
userSessionRepository: UserSessionRepository)
-> MainViewController {
let launchViewController = makeLaunchViewController(
userSessionRepository: userSessionRepository,
notSignedInResponder: mainViewModel,
signedInResponder: mainViewModel)
return MainViewController(
viewModel: mainViewModel,
launchViewController: launchViewController)
}
func makeLaunchViewController(
userSessionRepository: UserSessionRepository,
notSignedInResponder: NotSignedInResponder,
signedInResponder: SignedInResponder)
-> LaunchViewController {
let viewModel = makeLaunchViewModel(
userSessionRepository: userSessionRepository,
notSignedInResponder: notSignedInResponder,
signedInResponder: signedInResponder)
return LaunchViewController(viewModel: viewModel)
}
// 2
func makeLaunchViewModel(
userSessionRepository: UserSessionRepository,
notSignedInResponder: NotSignedInResponder,
signedInResponder: SignedInResponder) -> LaunchViewModel {
return LaunchViewModel(
userSessionRepository: userSessionRepository,
notSignedInResponder: notSignedInResponder,
signedInResponder: signedInResponder)
}
}
Again, the factories approach is about centralizing dependency and object-under-construction instantiation into a factories class. Here are a couple things to note about the above code:
-
Notice how this factory method has a couple of parameters. Because
KooberObjectFactoriesis stateless and has no idea where long-lived dependencies are held, you have to pass long-lived dependencies into factory methods in the factories approach. In this line, both theviewModelanduserSessionRepositoryparameters are long-lived. -
This is another factory method that needs to have dependencies passed in from the outside.
LaunchViewModelneeds objects that conform toNotSignedInResponderandSignedInResponder. You and I know thatMainViewModelconforms to this, butKooberObjectFactoriesdoes not know becauseKooberObjectFactoriesdoes not manage long-lived dependencies andMainViewModelis a long-lived dependency. Therefore,KooberObjectFactoriescannot create aNotSignedInRespondernor aSignedInResponder.
In the on-demand example, the equivalent code was in application(_:didFinishLaunchingWithOptions:). Speaking of application(_:didFinishLaunchingWithOptions:), now that the instantiations are centralized, what does this method look like?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let sharedMainViewModel = GlobalMainViewModel
let sharedUserSessionRepository = GlobalUserSessionRepository
let objectFactories = KooberObjectFactories()
let mainViewController =
objectFactories.makeMainViewController(
viewModel: sharedMainViewModel,
userSessionRepository: sharedUserSessionRepository)
window.frame = UIScreen.main.bounds
window.makeKeyAndVisible()
window.rootViewController = mainViewController
return true
}
The above code gets shared instances needed to create a MainViewController, creates a factories class instance, and finally creates a MainViewController using the factories class instance.
No matter how complicated MainViewController’s dependency graph gets, the above code, for the most part, stays the same.
For instance, if MainViewController needed five more ephemeral dependencies, the above code wouldn’t change, at all. That’s because the code responsible for building ephemeral dependencies needed by MainViewController’s dependency graph is no longer in application(_:didFinishLaunchingWithOptions:), it’s now in KooberObjectFactories.
On the other hand, if MainViewController needed more long-lived dependencies, the above code would need to change a little bit in order to access the long-lived dependencies. As you’ll see later, this won’t be the case when upgrading a factories class to a container class.
Remember, a factories class is stateless. You can instantiate the class whenever you need to invoke a factory method. Just remember that doing this inside any object other than the app delegate can make your objects harder to unit test.
Great! You now know how to design a simple factories class. What about that complex initializer you saw earlier? What code would need to be added? This example is going to go from using the following MainViewController initializer:
public init(viewModel: MainViewModel,
launchViewController: LaunchViewController)
To the following initializer:
public init(viewModel: MainViewModel,
launchViewController: LaunchViewController,
onboardingViewControllerFactory:
@escaping () -> OnboardingViewController)
If you recall Koober’s real MainViewController initializer you saw earlier, you’ll notice this new version isn’t exactly the same. This version is missing the last factory closure parameter. That’s because walking through adding the last factory closure parameter would take forever.
The good news: If you understand how the version above works, you’ll understand how the real version works as well!
Also, here’s the updated dependency graph that corresponds:
The next part of this example demonstrates how to apply the theory about injecting factories into an object-under-construction. Here’s the first bit of code:
class KooberObjectFactories {
// Factories needed to create a UserSessionRepository.
...
// Factories needed to create a MainViewController.
func makeMainViewController(
viewModel: MainViewModel,
userSessionRepository: UserSessionRepository)
-> MainViewController {
let launchViewController = makeLaunchViewController(
userSessionRepository: userSessionRepository,
notSignedInResponder: mainViewModel,
signedInResponder: mainViewModel)
// The type of this constant is
// () -> OnboardingViewController.
// The compiler will infer this type once the closure
// is implemented.
let onboardingViewControllerFactory = {
// Return a new on-boarding view controller here.
...
}
return MainViewController(
viewModel: mainViewModel,
launchViewController: launchViewController,
// New factory closure argument:
onboardingViewControllerFactory:
onboardingViewControllerFactory)
}
...
}
The above code modifies MainViewController’s factory method to account for the factory injected version of MainViewController’s initializer. Notice how the example adds a closure constant named onboardingViewControllerFactory. This factory closure is injected into MainViewController via initialization.
The body of onboardingViewControllerFactory should create and return a new OnboardingViewController. The body is empty in the example above because KooberObjectFactories is missing factory methods for creating OnboardingViewControllers.
The following code adds those factory methods:
class KooberObjectFactories {
// Factories needed to create a UserSessionRepository.
...
// Factories needed to create a MainViewController.
...
// Factories needed to create an OnboardingViewController.
func makeOnboardingViewController(
userSessionRepository: UserSessionRepository,
signedInResponder: SignedInResponder)
-> OnboardingViewController {
let onboardingViewModel = makeOnboardingViewModel()
let welcomeViewController = makeWelcomeViewController(
goToSignUpNavigator: onboardingViewModel,
goToSignInNavigator: onboardingViewModel)
let signInViewController = makeSignInViewController(
userSessionRepository: userSessionRepository,
signedInResponder: signedInResponder)
let signUpViewController = makeSignUpViewController(
userSessionRepository: userSessionRepository,
signedInResponder: signedInResponder)
return OnboardingViewController(
viewModel: onboardingViewModel,
welcomeViewController: welcomeViewController,
signInViewController: signInViewController,
signUpViewController: signUpViewController)
}
func makeOnboardingViewModel() -> OnboardingViewModel {
return OnboardingViewModel()
}
func makeWelcomeViewController(
goToSignUpNavigator: GoToSignUpNavigator,
goToSignInNavigator: GoToSignInNavigator)
-> WelcomeViewController {
let viewModel = makeWelcomeViewModel(
goToSignUpNavigator: goToSignUpNavigator,
goToSignInNavigator: goToSignInNavigator)
return WelcomeViewController(viewModel: viewModel)
}
func makeWelcomeViewModel(
goToSignUpNavigator: GoToSignUpNavigator,
goToSignInNavigator: GoToSignInNavigator)
-> WelcomeViewModel {
return WelcomeViewModel(
goToSignUpNavigator: goToSignUpNavigator,
goToSignInNavigator: goToSignInNavigator)
}
func makeSignInViewController(
userSessionRepository: UserSessionRepository,
signedInResponder: SignedInResponder)
-> SignInViewController {
let viewModel = makeSignInViewModel(
userSessionRepository: userSessionRepository,
signedInResponder: signedInResponder)
return SignInViewController(viewModel: viewModel)
}
func makeSignInViewModel(
userSessionRepository: UserSessionRepository,
signedInResponder: SignedInResponder)
-> SignInViewModel {
return SignInViewModel(
userSessionRepository: userSessionRepository,
signedInResponder: signedInResponder)
}
func makeSignUpViewController(
userSessionRepository: UserSessionRepository,
signedInResponder: SignedInResponder)
-> SignUpViewController {
let viewModel = makeSignUpViewModel(
userSessionRepository: userSessionRepository,
signedInResponder: signedInResponder)
return SignUpViewController(viewModel: viewModel)
}
func makeSignUpViewModel(
userSessionRepository: UserSessionRepository,
signedInResponder: SignedInResponder)
-> SignUpViewModel {
return SignUpViewModel(
userSessionRepository: userSessionRepository,
signedInResponder: signedInResponder)
}
}
Wow — OnboardingViewController has quite a dependency graph. The above code was previously in MainViewController’s presentOnboarding() method in the on-demand version of this example. The complexity of assembling OnboardingViewController’s object graph has now moved outside of MainViewController. This allows MainViewController to focus on being a great view controller.
KooberObjectFactories now has the ability to create OnboardingViewControllers. The following example illustrates how to use this ability inside the onboardingViewControllerFactory closure:
class KooberObjectFactories {
// Factories needed to create a UserSessionRepository.
...
// Factories needed to create a MainViewController.
func makeMainViewController(
viewModel: MainViewModel,
userSessionRepository: UserSessionRepository)
-> MainViewController {
let launchViewController = makeLaunchViewController(
userSessionRepository: userSessionRepository,
notSignedInResponder: mainViewModel,
signedInResponder: mainViewModel)
// Closure factory now implemented:
let onboardingViewControllerFactory = {
// Factories class is stateless, therefore
// there’s no chance for a retain cycle here.
return self.makeOnboardingViewController(
userSessionRepository: userSessionRepository,
signedInResponder: mainViewModel)
}
return MainViewController(
viewModel: mainViewModel,
launchViewController: launchViewController,
onboardingViewControllerFactory:
onboardingViewControllerFactory)
}
...
// Factories needed to create an OnboardingViewController.
...
}
In the example above, the onboardingViewControllerFactory closure simply invokes the OnboardingViewController factory method from earlier. The closure captures the userSessionRepository and mainViewModel arguments passed into MainViewController’s factory method.
These objects are used to invoke OnboardingViewController’s factory method. The closure also captures self, i.e., the KooberObjectFactories instance.
So, in an indirect way, MainViewController holds a reference to KooberObjectFactories and that’s OK because KooberObjectFactories is stateless. There’s no chance for a retain cycle to materialize.
That’s how you inject factories! There was a lot of changes necessary in order to give MainViewController the power to create OnboardingViewControllers. Take a look at how application(_:didFinishLaunchingWithOptions:) needs to change to account for all this:
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let sharedMainViewModel = GlobalMainViewModel
let sharedUserSessionRepository = GlobalUserSessionRepository
let objectFactories = KooberObjectFactories()
let mainViewController =
objectFactories.makeMainViewController(
viewModel: sharedMainViewModel,
userSessionRepository: sharedUserSessionRepository)
window.frame = UIScreen.main.bounds
window.makeKeyAndVisible()
window.rootViewController = mainViewController
return true
}
Wait a second… that’s right: nothing changed. You’ve now witnessed the awesome power of DI. Also, remember that very long presentOnboarding() method from the on-boarding example?
Take a look at that method now:
public func presentOnboarding() {
let onboardingViewController = makeOnboardingViewController()
onboardingViewController.modalPresentationStyle = .fullScreen
present(onboardingViewController, animated: true) { ... }
self.onboardingViewController = onboardingViewController
}
In order to create a new OnboardingViewController, MainViewController just has to invoke the empty argument makeOnboardingViewController closure property. MainViewController doesn’t have to know anything about the dependency graph needed to create a new OnboardingViewController. Cool!
You’re starting to become a DI guru. But wait — there’s more. The one problem with KooberObjectFactories is you have to create global constants for long-lived dependencies. You probably don’t want these objects just hanging out in global space. To solve this, you’ll see how you can upgrade KooberObjectFactories to a KooberAppDependencyContainer in the next section.
Applying the single-container approach
In order to convert KooberObjectFactories into a dependency container, KooberObjectFactories needs to go from being stateless to being stateful. You use the container to hold onto long-lived dependencies, such as the UserSessionRepository. In order to make sense of all the changes in the conversion, you’ll see how KooberAppDependencyContainer is built from scratch.
The first order of business is to create and store the shared UserSessionRepository:
class KooberAppDependencyContainer {
// MARK: - Properties
// 1
let sharedUserSessionRepository: UserSessionRepository
// MARK: - Methods
init() {
// 2
func makeUserSessionRepository() -> UserSessionRepository {
let dataStore = makeUserSessionDataStore()
let remoteAPI = makeAuthRemoteAPI()
return KooberUserSessionRepository(dataStore: dataStore,
remoteAPI: remoteAPI)
}
func makeUserSessionDataStore() -> UserSessionDataStore {
#if USER_SESSION_DATASTORE_FILEBASED
return FileUserSessionDataStore()
#else
let coder = makeUserSessionCoder()
return KeychainUserSessionDataStore(
userSessionCoder: coder)
#endif
}
func makeUserSessionCoder() -> UserSessionCoding {
return UserSessionPropertyListCoder()
}
func makeAuthRemoteAPI() -> AuthRemoteAPI {
return FakeAuthRemoteAPI()
}
// 3
self.sharedUserSessionRepository =
makeUserSessionRepository()
}
}
Here’s what each part does:
- This declares a constant stored property. This property holds onto the shared
UserSessionRepositoryinstance that should be used when creating an object-under-construction that depends on aUserSessionRepository. - Notice how these factory methods are inside the container’s initializer. These factory methods cannot be instance methods because Swift does not allow an initializer to call a method on
selfuntil all stored properties are initialized. In this case, you need these methods to initialize a stored property. - The shared
UserSessionRepositorystored property is initialized with aUserSessionRepositorycreated by the inlined factory methods.
The example above gives the container the ability to fully create and store a shared UserSessionRepository. Next, you’ll look at how to give the container the ability to create a MainViewController.
MainViewController needs three big things in order to be instantiated: A shared MainViewModel, an OnboardingViewController factory closure, and a LaunchViewController. You’ll add factory methods for these dependencies in this order.
MainViewModel is first. The shared MainViewModel is another global long-lived dependency that needs to move into the container. The following code adds the sharedMainViewModel into KooberAppDependencyContainer:
class KooberAppDependencyContainer {
// MARK: - Properties
let sharedUserSessionRepository: UserSessionRepository
// 1
let sharedMainViewModel: MainViewModel
// MARK: - Methods
init() {
func makeUserSessionRepository() -> UserSessionRepository {
let dataStore = makeUserSessionDataStore()
let remoteAPI = makeAuthRemoteAPI()
return KooberUserSessionRepository(dataStore: dataStore,
remoteAPI: remoteAPI)
}
func makeUserSessionDataStore() -> UserSessionDataStore {
#if USER_SESSION_DATASTORE_FILEBASED
return FileUserSessionDataStore()
#else
let coder = makeUserSessionCoder()
return KeychainUserSessionDataStore(
userSessionCoder: coder)
#endif
}
func makeUserSessionCoder() -> UserSessionCoding {
return UserSessionPropertyListCoder()
}
func makeAuthRemoteAPI() -> AuthRemoteAPI {
return FakeAuthRemoteAPI()
}
// 2
// Because `MainViewModel` is a concrete type
// and because `MainViewModel`’s initializer has
// no parameters, you don’t need this inline
// factory method, you can also initialize the
// `sharedMainViewModel` property on the
// declaration line like this:
// `let sharedMainViewModel = MainViewModel()`.
// Which option to use is a style preference.
func makeMainViewModel() -> MainViewModel {
return MainViewModel()
}
self.sharedUserSessionRepository =
makeUserSessionRepository()
// 3
self.sharedMainViewModel =
makeMainViewModel()
}
}
Here’s what each part does:
- This line adds a constant stored property to hold onto a shared
MainViewModel. The container will use this instance any time an object-under-construction needs aMainViewModel. - This block adds a new inlined
MainViewModelfactory method toinit. One neat thing is this design guarantees that anotherMainViewModelwon’t be accidentally created because this factory method is inaccessible outsideinit. - The shared
MainViewModelis created and used to initialize thesharedMainViewModelproperty.
That’s the MainViewModel dependency, next is OnboardingViewController:
class KooberAppDependencyContainer {
// MARK: - Properties
let sharedUserSessionRepository: UserSessionRepository
let sharedMainViewModel: MainViewModel
// 1
var sharedOnboardingViewModel: OnboardingViewModel?
// MARK: - Methods
init() {
...
}
// 2
// On-boarding (signed-out)
// Factories needed to create an OnboardingViewController.
func makeOnboardingViewController()
-> OnboardingViewController {
// 3
self.sharedOnboardingViewModel = makeOnboardingViewModel()
let welcomeViewController = makeWelcomeViewController()
let signInViewController = makeSignInViewController()
let signUpViewController = makeSignUpViewController()
// 4
return OnboardingViewController(
viewModel: self.sharedOnboardingViewModel!,
welcomeViewController: welcomeViewController,
signInViewController: signInViewController,
signUpViewController: signUpViewController)
}
func makeOnboardingViewModel() -> OnboardingViewModel {
return OnboardingViewModel()
}
func makeWelcomeViewController() -> WelcomeViewController {
let viewModel = makeWelcomeViewModel()
return WelcomeViewController(viewModel: viewModel)
}
func makeWelcomeViewModel() -> WelcomeViewModel {
return WelcomeViewModel(
goToSignUpNavigator: self.sharedOnboardingViewModel!,
goToSignInNavigator: self.sharedOnboardingViewModel!)
}
func makeSignInViewController() -> SignInViewController {
let viewModel = makeSignInViewModel()
return SignInViewController(viewModel: viewModel)
}
func makeSignInViewModel() -> SignInViewModel {
return SignInViewModel(
userSessionRepository: self.sharedUserSessionRepository,
signedInResponder: self.sharedMainViewModel)
}
func makeSignUpViewController() -> SignUpViewController {
let viewModel = makeSignUpViewModel()
return SignUpViewController(viewModel: viewModel)
}
func makeSignUpViewModel() -> SignUpViewModel {
return SignUpViewModel(
userSessionRepository: self.sharedUserSessionRepository,
signedInResponder: self.sharedMainViewModel)
}
}
Notice how the factory methods don’t have parameters anymore! That’s because factory methods in a container can use other factory methods to create ephemeral dependencies and because factory methods in a container can access the container’s properties to get long-lived dependencies. Containers have everything they need to assemble entire dependency graphs.
Here are some additional things to note about the above code:
- This adds an optional stored property to hold onto a shared
OnboardingViewModel. This property is optional because anOnboardingViewModelis only needed when a user is not signed in to Koober. This property starts out with a nil value. - All the factory methods for all dependencies in
OnboardingViewController’s dependency graph are added here. - This line creates a new
OnboardingViewModelevery time a newOnboardingViewControlleris created. ThisOnboardingViewModelis stored in the container’ssharedOnboardingViewModel.OboardingViewModels are stateful and therefore, the same view model instance should be used for the lifetime of theOnboardingViewControllerinstance created in this factory method. Later, you’ll see how to improve this by separating the on-boarding factory methods into a scoped container. - This line creates a new
OnboardingViewControllerby using thesharedOnboardingViewModelas well as all the view controllers theOnboardingViewControllerneeds. Yes, the force unwrap is ugly. You’ll see how to get rid of this later when learning how to separate this logic into a scoped container.
MainViewModel? Check. OnboardingViewController? Check. It’s time to look at LaunchViewController:
class KooberAppDependencyContainer {
// MARK: - Properties
let sharedUserSessionRepository: UserSessionRepository
let sharedMainViewModel: MainViewModel
var sharedOnboardingViewModel: OnboardingViewModel?
// MARK: - Methods
init() {
...
}
// On-boarding (signed-out)
// Factories needed to create an OnboardingViewController.
...
// Main
// Factories needed to create a MainViewController.
func makeLaunchViewController() -> LaunchViewController {
let viewModel = makeLaunchViewModel()
return LaunchViewController(viewModel: viewModel)
}
func makeLaunchViewModel() -> LaunchViewModel {
return LaunchViewModel(
userSessionRepository: self.sharedUserSessionRepository,
notSignedInResponder: self.sharedMainViewModel,
signedInResponder: self.sharedMainViewModel)
}
}
There’s nothing too surprising, here. The above code adds two factory methods: one to create a LaunchViewModel, which is then used to create a LaunchViewController in the other factory method.
All the setup is complete. The only thing missing is a factory method that can create a MainViewController:
class KooberAppDependencyContainer {
// MARK: - Properties
let sharedUserSessionRepository: UserSessionRepository
let sharedMainViewModel: MainViewModel
var sharedOnboardingViewModel: OnboardingViewModel?
// MARK: - Methods
init() {
...
}
// On-boarding (signed-out)
// Factories needed to create an OnboardingViewController.
...
// Main
// Factories needed to create a MainViewController.
func makeMainViewController() -> MainViewController {
// 1
let launchViewController = makeLaunchViewController()
// 2
let onboardingViewControllerFactory = {
return self.makeOnboardingViewController()
}
// 3
return MainViewController(
viewModel: self.sharedMainViewModel,
launchViewController: launchViewController,
onboardingViewControllerFactory:
onboardingViewControllerFactory)
}
...
}
Here’s what each part does:
- This line creates a
LaunchViewController. - Look how simple the
OnboardingViewControllerfactory closure is, now that theOnboardingViewControllerfactory method takes no arguments. - This is what you’ve been waiting for: the line that creates the
MainViewController. This line uses a long-lived dependency, a newly created dependency and a factory closure to create aMainViewController. All the big concepts, wrapped up into a single line.
OK, the container is setup and ready to build Koober’s object graph. It’s time for the very final step — making a MainViewController and its entire graph when Koober launches:
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
// MARK: - Properties
// 1
let appContainer = KooberAppDependencyContainer()
let window = UIWindow()
// MARK: - Methods
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 2
let mainVC = appContainer.makeMainViewController()
window.frame = UIScreen.main.bounds
window.makeKeyAndVisible()
window.rootViewController = mainVC
return true
}
}
It only takes two steps to create Koober’s entire dependency graph. With the above code, you:
- Create the app container and store it in a constant inside the app delegate. Creating this container is easy because the initializer doesn’t have any parameters. Remember, you should only create one instance of a container because containers are stateful unlike a factories class.
- Create the root object, in this case a
MainViewController, by invoking the root object’s factory method on the container. This single line creates and sets up everything Koober needs in order to run. All dependencies are provided from the outside.
The awesome thing about all this is that all of the classes inside Koober have no idea about the dependency containers. It’s not like using DI will introduce a bunch of things into your existing code that you might want to get rid of later.
What a journey it’s been. You’ve seen all big three approaches used in practice. You’re almost at the finish line! The only pesky thing that needs addressing is the optional sharedOnboardingViewModel. Don’t you hate it when you find yourself needing to force unwrap something? I know I do. In the next section, you’ll see how to address this issue by separating the on-boarding factory logic into a separate scoped container.
Applying the container hierarchy approach
The first step to creating a scoped container for the on-boarding logic is to remove all the on-boarding factory methods from KooberAppDependencyContainer:
class KooberAppDependencyContainer {
// MARK: - Properties
// Long-lived dependencies
let sharedUserSessionRepository: UserSessionRepository
let sharedMainViewModel: MainViewModel
// MARK: - Methods
init() {
func makeUserSessionRepository() -> UserSessionRepository {
let dataStore = makeUserSessionDataStore()
let remoteAPI = makeAuthRemoteAPI()
return KooberUserSessionRepository(dataStore: dataStore,
remoteAPI: remoteAPI)
}
func makeUserSessionDataStore() -> UserSessionDataStore {
#if USER_SESSION_DATASTORE_FILEBASED
return FileUserSessionDataStore()
#else
let coder = makeUserSessionCoder()
return KeychainUserSessionDataStore(
userSessionCoder: coder)
#endif
}
func makeUserSessionCoder() -> UserSessionCoding {
return UserSessionPropertyListCoder()
}
func makeAuthRemoteAPI() -> AuthRemoteAPI {
return FakeAuthRemoteAPI()
}
func makeMainViewModel() -> MainViewModel {
return MainViewModel()
}
self.sharedUserSessionRepository =
makeUserSessionRepository()
self.sharedMainViewModel =
makeMainViewModel()
}
// Main
// Factories needed to create a MainViewController.
func makeMainViewController() -> MainViewController {
let launchViewController = makeLaunchViewController()
let onboardingViewControllerFactory = {
return self.makeOnboardingViewController()
}
return MainViewController(
viewModel: self.sharedMainViewModel,
launchViewController: launchViewController,
onboardingViewControllerFactory:
onboardingViewControllerFactory)
}
// Launching
func makeLaunchViewController() -> LaunchViewController {
let viewModel = makeLaunchViewModel()
return LaunchViewController(viewModel: viewModel)
}
func makeLaunchViewModel() -> LaunchViewModel {
return LaunchViewModel(
userSessionRepository: self.sharedUserSessionRepository,
notSignedInResponder: self.sharedMainViewModel,
signedInResponder: self.sharedMainViewModel)
}
// On-boarding (signed-out)
// Factories needed to create an OnboardingViewController.
func makeOnboardingViewController()
-> OnboardingViewController {
fatalError("This method needs to be implemented.")
}
}
The above code is the exact same KooberAppDependencyContainer from before except without all the on-boarding factory methods barring the primary OnboardingViewController factory method, makeOnboardingViewController().
This method will use the child on-boarding dependency container in order to create an OnboardingViewController. You’ll see the implementation of this method at the end of this example once you’ve explored the child on-boarding container’s class.
Next, you’ll explore a new container class that represents the on-boarding scope. Koober transitions into the on-boarding scope when Koober determines a user is not signed in. This could occur at launch or when a user signs out.
Here’s what the on-boarding scoped container class looks like:
class KooberOnboardingDependencyContainer {
// MARK: - Properties
// 1
// From parent container
let sharedUserSessionRepository: UserSessionRepository
let sharedMainViewModel: MainViewModel
// 2
// Long-lived dependencies
let sharedOnboardingViewModel: OnboardingViewModel
// MARK: - Methods
// 3
init(appDependencyContainer: KooberAppDependencyContainer) {
// 4
func makeOnboardingViewModel() -> OnboardingViewModel {
return OnboardingViewModel()
}
// 5
self.sharedUserSessionRepository =
appDependencyContainer.sharedUserSessionRepository
self.sharedMainViewModel =
appDependencyContainer.sharedMainViewModel
// 6
self.sharedOnboardingViewModel =
makeOnboardingViewModel()
}
// 7
// On-boarding (signed-out)
// Factories needed to create an OnboardingViewController.
func makeOnboardingViewController()
-> OnboardingViewController {
let welcomeViewController = makeWelcomeViewController()
let signInViewController = makeSignInViewController()
let signUpViewController = makeSignUpViewController()
return OnboardingViewController(
viewModel: self.sharedOnboardingViewModel,
welcomeViewController: welcomeViewController,
signInViewController: signInViewController,
signUpViewController: signUpViewController)
}
func makeWelcomeViewController() -> WelcomeViewController {
let viewModel = makeWelcomeViewModel()
return WelcomeViewController(viewModel: viewModel)
}
func makeWelcomeViewModel() -> WelcomeViewModel {
return WelcomeViewModel(
goToSignUpNavigator: self.sharedOnboardingViewModel,
goToSignInNavigator: self.sharedOnboardingViewModel)
}
func makeSignInViewController() -> SignInViewController {
let viewModel = makeSignInViewModel()
return SignInViewController(viewModel: viewModel)
}
func makeSignInViewModel() -> SignInViewModel {
return SignInViewModel(
userSessionRepository: self.sharedUserSessionRepository,
signedInResponder: self.sharedMainViewModel)
}
func makeSignUpViewController() -> SignUpViewController {
let viewModel = makeSignUpViewModel()
return SignUpViewController(viewModel: viewModel)
}
func makeSignUpViewModel() -> SignUpViewModel {
return SignUpViewModel(
userSessionRepository: self.sharedUserSessionRepository,
signedInResponder: self.sharedMainViewModel)
}
}
Here’s what each part does:
-
These two long-lived dependencies are held by the app dependency container. Instead of holding onto the app dependency container, this example holds onto the long-lived dependencies themselves. This is so the factory methods in this on-boarding container can have easy access to the long-lived dependencies without needing to know how to fish for the dependencies out of the app dependency container.
-
This line declares the scoped
sharedOnboardingViewModellong-lived dependency. This long-lived dependency only lives as long as this container lives. Most importantly, notice how this property is a constant and not optional. -
This is the container’s initializer. Notice how the app dependency container is required in order to create this on-boarding container. That’s because the objects, that this container creates, need long-lived dependencies held by the app dependency container. The app dependency container is the on-boarding container’s parent container.
-
This adds an inline factory method that creates a shared
OnboardingViewModel.OnboardingViewModelis stateful and therefore needs to be stored in a property. Since the property needs to be set in the initializer,OnboardingViewModel’s factory method needs to be inlined inside the initializer. -
These lines find the long-lived dependencies held by the parent app dependency container and uses those dependencies to set corresponding properties on this child container. The properties are needed so that the on-boarding dependency container can hold onto these long-lived dependencies. Holding dependencies from a parent container is OK because parent containers outlive child containers. There’s no chance this child container is holding onto something for longer than it should.
-
The shared
OnboardingViewModelis created here, using the inlined factory method. -
Here are all the factory methods that used to be in the app dependency container. The only difference here is that
sharedOnboardingViewModelis no longer forced unwrapped.
There’s one step left. Recall that MainViewController’s factory method needs to be able to create a new OnboardingViewController inside the factory closure that gets injected into MainViewController.
To do this, the factory closure needs KooberAppDependencyContainer’s makeOnboardingViewController() to be implemented.
Here’s what makeOnboardingViewController()’s implementation looks like:
class KooberAppDependencyContainer {
// MARK: - Properties
let sharedUserSessionRepository: UserSessionRepository
let sharedMainViewModel: MainViewModel
// MARK: - Methods
init() {
...
}
// Factories needed to create a MainViewController.
...
// Factories needed to create an OnboardingViewController.
func makeOnboardingViewController()
-> OnboardingViewController {
// 1
let onboardingDependencyContainer =
KooberOnboardingDependencyContainer(
appDependencyContainer: self)
// 2
return onboardingDependencyContainer
.makeOnboardingViewController()
}
}
Making a new OnboardingViewController from the app dependency container is a two-step process:
-
First, you need to create the child on-boarding dependency container using
self, the parent app dependency container. -
Finally, you use the child container to create and return a new
OnboardingViewController.
OK — the container hierarchy is set up and ready to build Koober’s object graph. It’s time for the very final step — making a MainViewController and its entire graph when Koober launches:
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
// MARK: - Properties
let appContainer = KooberAppDependencyContainer()
let window = UIWindow()
// MARK: - Methods
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let mainVC = appContainer.makeMainViewController()
window.frame = UIScreen.main.bounds
window.makeKeyAndVisible()
window.rootViewController = mainVC
return true
}
}
Yup, this code hasn’t changed at all. Refactoring the single container into a container hierarchy did not affect the consuming code. Cool, right? And that wraps up going through Koober’s use of DI!
Congratulations; you made it to the end! By practicing all the techniques you saw in this chapter, you’ll become a DI master in no time. Everything you learned in this chapter is the foundation needed to design well-architected object-oriented software. That’s right — you’ll even be able to use these techniques outside of mobile development. Taking the time to solidify your comfort level with DI will pay off big time. Make sure you have a good understanding of DI before moving on to the next chapters so that you can easily navigate the sample codebases.
Key points
-
The iOS SDK is object oriented; therefore, you use object-oriented techniques to design well-architected iOS apps.
-
There are many beneficial goals including testability and maintainability that can be achieved by managing object dependencies.
-
Consumers need objects-under-construction and objects-under-construction need transitive dependencies. Together, these objects form an object graph.
-
Accessing dependencies, determining substitutability and designing substitutability form the basis of the three fundamental questions you need to answer to reap the benefits of managing object dependencies.
-
Dependency Injection, Service Locator, Environment and Protocol extensions are the main dependency patterns used by iOS app developers.
-
Dependency Injection (DI) is all about providing dependencies from the outside of objects.
-
There are three types of DI: Initializer, property and method injection.
-
You saw how to apply DI four ways: On-demand, Factories, Single Container and Container Hierarchy.
-
When applying the DI pattern, your goal is to construct a flow, or a screen, entire object graph upfront.
-
When an object-under-construction needs to create multiple instances of a dependency, you inject a factory closure or you inject an object that conforms to a factory protocol.
Where to go from here?
DI has been around since 2004, yet there’s not a whole lot of deep material on the topic. Most of the content you’ll find teaches you how to use a DI library. However, there are a couple of great resources you can explore to learn more:
-
Inversion of Control Containers and the Dependency Injection pattern by Martin Fowler. This is the original post that introduces DI. It’s a great read if you want to get a sense for the origin of the pattern and why it came to be.
-
Dependency Injection in .NET by Mark Seemann. Although this book uses .NET for example code, the material is applicable to any object-oriented language. This is probably the most thorough treatment of DI available.
If you’re interested in learning how to apply DI using a library, check out Gemma Barlow’s tutorial on Swinject: Swinject Tutorial for iOS: Getting Started.
Which chapter should you read next? Since the next chapters do not build upon each other, it’s really up to you which chapter you dive into next. Enjoy!