Scalable modern architecture for iOS projects — Part 2
In the second part of the series, we will cover the general definition of the architecture following the principles we defined in the first article. We’ll also start building the building blocks that will allow us to implement the architecture quickly and consistently.
General architecture definition and implementation
No architectural definition is complete without a nice architecture diagram. The following image describes the different elements of our architecture and how they interact with each other.
The different elements of the architecture are:
- ApplicationContext: This important object holds all the “global context” of the application. It’s a concept borrowed from Redux architectures that increases the testability of the application and provides a single source of truth across the different areas of the application. The context gets injected in all the scene coordinators and scenes. Think of a simplified UIKit’s version of SwiftUI’s environment, but testable and easily extendable.
- Scene Coordinator: Simplified Coordinator (also called Flow Coordinators) in charge of managing a single navigation flow; for example, you’ll have a SceneCoordinator for Signing In, another one for Registering, etc. SceneCoordinator should be kept small; if you deal with a complex flow, think about dividing the flow into smaller ones. Keeping them small is that you could potentially reuse them in different parts of the application.
- Scene: A Scene is just a logical group of an MVVM module, meaning a ViewModel
, a ViewController
(more on the role of ViewControllers later) and a series of UIView
(s) that compose the screen. The ViewController does not have any other dependency than the ViewModel. All the data flow is managed through a series of inputs and outputs, allowing the ViewModel to decouple the View completely. This approach is essential to enable high Unit Test coverage (we could test the inputs and outputs and ensure most of the system behaves as expected) and simplify the composition. The ViewModel could have as many dependencies as needed to fulfil its mission; they are simple parameters in its `init` method. This pattern also enables simple and effective Snapshot Testing to reduce the number of slower UI Tests needed, covered in the article about testing.
One of the most complex parts to manage in a modern and scalable iOS application is navigation. Borrowing a concept from most web frameworks, we can define the navigation in the application as a series of routes with the required parameters; aside from simplifying the coordinators’ responsibilities, using Swift’s strong type system, we can enforce and document the requirements of each scene/flow, facilitating reuse and making them easier to understand.
Let’s start writing some code that will define each of the different components.
Coordinators
Both the main AppCoordinator
and the SceneCoordinators
extend the same “base” coordinator code; the only difference is that the AppCoordinator is never expected to be released (if it does, something went wrong). We start by defining an abstract coordinator type, Swift does not have abstract types, but we enforce subtyping by adding a `fatalError` in the methods that need implementation.
There is quite a lot happening in there; the central concept is that using Inversion Of Control (IoC) the parent coordinator, or the AppDelegate in the case of the AppCoordinator relinquishes control of the flow to this coordinator when one of the start
methods is called. The coordinator can also “give” control to another coordinator when one of the `coordinate` methods is called, allowing for the composition of complex flows using small coordinators.
We only expose one property, sceneFinished
is a PublishSubject
that will emit a route back to whoever initialised the flow by calling one of the start
methods of the coordinator, this will allow the “parent” to regain control and even pass values back if needed. The coordinator is generic over a Route
, which in most cases will be an enum defining the different possible routes that coordinator/flow handles. For example:
This Route
type defines four possible paths, showing the details for the given project, show all its items, add a new project, or just going back, which will finish the flow.
We also expose four public methods, two options to start and two chances to give control to a different coordinator; it could be reduced to just two methods using optionals and defaults, but it’s a personal preference to use method overloading. Without parameters, the start
method starts the flow in the default Route
, which is defined when implementing the actual coordinator. In contrast, the start(in:)
method starts the flow in the given Route
, it’s the developer’s responsibility to add the necessary code to prevent users from creating the flow in a broken state (not all flows can start from any step). Many implementations of this pattern don’t even include the option of starting the flow from a different Route
than the default one; it is up to you and your team if you want to include it or not; in complex applications, it provides flexibility but also increases complexity. The same applies to the coordinate
methods, which transfer control to the given coordinator and waits for it to finish until it retakes control.
The rest of the implementation is just housekeeping to retain and release the different children coordinators, so they are not deallocated until they finish. We store them in a Dictionary
using a UUID
as a unique identifier for each one, which will allow us to have more than one instance of the same type if we need to; once the flow finishes and the control returns to the parent coordinator, the “child” coordinator is removed from the dictionary so it can be released when appropriate.
An example of how to use a coordinator from the AppCoordinator
could be:
and if we want to start the flow to add a new project, it could be:
Application Context
The Application Context is an object that abstracts several application layers and provides a manageable and testable “global” state. It is a concept borrowed from *Redux* architectures. Its primary purpose is defined as:
> Provides a single source of truth for the application state at any point during the application’s execution.
Let’s have a look at the diagram defining it:
As per the diagram, the context is composed of three different sections and an optional one. Let’s start defining the state blocks:
- Immutable State: Is the State that never changes during the execution of the application, like UIApplication
, UIDevice
, unique identifiers, etc. The purpose of having them in the context when they can be accessed directly through singletons is to facilitate testing. So, for example, if we need to test the UI using Snapshot Testing in specific size classes, we can inject a context the sets its device as the desired one.
- Transient State: It’s application and system state that can change during the execution of the application but is not directly changed by our code, but by user or external agents interactions. NSUserActivity, Deeplinks, size classes, etc. Like the immutable one, having a centralised repository for those “events” or States will allow easier testing by injecting specific contexts.
- Mutable State: This is a simple one; it is just any state that must be stored and accessible from anywhere in the application. Its primary purpose is to provide a single source of truth across the application for that State, significantly if it can be mutated from different areas of the application or even external factors (for example, session expiring). Additionally, it also facilitates testing allowing us to inject that State through mocked contexts. It’s a powerful pattern, but as such, quite prone to abuse; remember: With great power comes great responsibility.
The DI Container is a complicated one, which I struggle with sometimes. Conceptually, it should not be part of the context because they address different concerns; being a separate component adds complexity to the application and testing but increases flexibility; having it as part of the context reduces flexibility but decreases complexity. Unfortunately, I don’t think there is a magic solution for this. That’s the reason why it is marked as optional; you should evaluate the best approach for your particular set of requirements.
I tend to follow the approach that if the number of objects managed by the container is small, I keep it as part of the context. If the number increases, I tend to decouple them but ensure that whatever approach I use for the DI container can be easily mocked for testing.
The three essential things to remember regarding the ApplicationContext are:
- The AppContext is injected anywhere needed by the relevant coordinator so that the relevant ViewModel can access it.
- The Context is never directly accessed by the views, only by the ViewModel; this approach simplifies possible refactors in the context layer.
- There isn’t a specific definition of the context; every application will have different requirements.
Because of this last point, I won’t be showing a generalised ApplicationContext; any class
that can store the values your application requires is more than OK. So instead, I’ll be sharing a specific implementation as part of the complete project.
In the following article of the series, I’ll keep exploring the different architecture components like ViewModels and some other optional patterns like Storage Context, Managers, etc.
The previous article covers the general definition of the architecture and the initial building blocks.