Chapters

Hide chapters

Advanced iOS App Architecture

Fourth Edition · iOS 15 · Swift 5.5 · Xcode 13.2

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

Section I

Section 1: 9 chapters
Show chapters Hide chapters

4. Objects & Their Dependencies
Written by René Cacheaux & Josh Berlin

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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.

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.

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:

From the outside:

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.

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.

Dependency patterns

Dependency Injection and Service Locator are the most-used patterns in software engineering.

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.

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.

Types of injection

There are three types of 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.

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.

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.

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.

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.

Pros of the on-demand approach

Cons of the on-demand approach

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.

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.

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.

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.

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.

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?

Using closures

One option is to add a factory closure stored-property to the object-under-construction. Here are the steps:

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:

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.

Pros of the factories approach

Cons of the factories 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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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?

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.

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.

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:

LaunchViewController’s dependency graph

These are all the protocols and objects needed to create a LaunchViewController:

OnboardingViewController’s dependency graph

OnboardingViewController depends on the following protocols and objects:

MainViewController’s dependency graph

Finally, here are the protocols and objects MainViewController depends on:

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)
let mainViewModel = MainViewModel()
public init(viewModel: LaunchViewModel)
public init(userSessionRepository: UserSessionRepository,
            notSignedInResponder: NotSignedInResponder,
            signedInResponder: SignedInResponder)

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.

// 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)
}()

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.

// 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)
}()

Creating a MainViewController

UserSessionRepository is the only shared instance needed to ultimately create a MainViewController.

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
}

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.

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
}

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.

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 code is global, it’s not in any type.
public let GlobalUserSessionRepository:
  UserSessionRepository = {

  let objectFactories =
    KooberObjectFactories()

  let userSessionRepository =
    objectFactories.makeUserSessionRepository()

  return userSessionRepository
}()

Creating a MainViewController

Recall the MainViewController initializer you saw in the on-demand example.

public init(viewModel: MainViewModel,
            launchViewController: LaunchViewController)
init(viewModel: MainViewModel,
     launchViewController: LaunchViewController,
     // Closure that creates an OnboardingViewController
     onboardingViewControllerFactory:
       @escaping () -> OnboardingViewController,
    // Closure that creates a SignedInViewController
     signedInViewControllerFactory:
       @escaping (UserSession) -> SignedInViewController)
class KooberObjectFactories {

  // Factories needed to create a UserSessionRepository.

  ...

  // Factories needed to create a MainViewController.

  func makeMainViewModel() -> MainViewModel {
    return MainViewModel()
  }
}
// This code is global, it’s not in any type.
public let GlobalMainViewModel: MainViewModel = {
  let objectFactories = KooberObjectFactories()
  let mainViewModel = objectFactories.makeMainViewModel()

  return mainViewModel
}()
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)
  }
}
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
}
public init(viewModel: MainViewModel,
            launchViewController: LaunchViewController)
public init(viewModel: MainViewModel,
            launchViewController: LaunchViewController,
            onboardingViewControllerFactory:
              @escaping () -> OnboardingViewController)

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)
  }

  ...
}
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)
  }
}
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.

  ...
}
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
}
public func presentOnboarding() {
  let onboardingViewController = makeOnboardingViewController()

  onboardingViewController.modalPresentationStyle = .fullScreen
  present(onboardingViewController, animated: true) { ... }
  self.onboardingViewController = onboardingViewController
}

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.

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()
  }
}
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()
  }
}
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)
  }
}
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)
  }
}
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)
  }

  ...
}
@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
  }
}

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.")
  }
}
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)
  }
}
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()
  }
}
@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
  }
}

Key points

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:

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now