The Healico project started in July 2020. It is an application available on Android and iOS designed to help nurses monitor their patients' wounds.
As a health software (but not a medical device), it must be highly reliable with as few bugs as possible. We also knew from the outset that the app would receive many updates, so it had to be maintainable. To meet these two constraints, we had to conceive an architecture that would address the challenges. In this article, we will outline the problems we faced and the solutions we chose, step by step.
⚠️ Disclaimer: The architecture described in this article may not be suitable for all projects. As previously explained, the main priority was to have a reliable app. Therefore, this architecture has some disadvantages, which can be overcome by advantages when the project is large enough.
The development team went through several changes during the project. It started with three developers and then fluctuated between one and four people. In order to maintain a clean project, the architecture we chose had to be fully understood by everyone. Otherwise, we risked deviating gradually from the architecture until we lost its advantages.
The developers were quite experienced, so we were able to build a complex architecture without risking losing them along the way.
Lastly, even though the team changed somewhat over the last 2.5 years, people often stayed on the project for several months. Therefore, we can have a long onboarding time as long as it becomes profitable over time.
To begin, there are three constraints to consider.
Firstly, the application is quite large (currently at over 75,000 lines of code), and we want to be able to build the project as quickly as possible to enable rapid iteration. One way to achieve this is to take advantage of Xcode's cache by splitting the app into multiple targets. When nothing changes inside a target, it is not built again. Therefore, when a change is made inside one target, only that target and the app container are built.
Secondly, with multiple developers working on the project, we want to avoid time wasted on git merging conflicts. We can have these developers work on different tracks, but files should be split to reflect the fact that different tracks are in different files.
Finally, we want to test our logic as much as possible to catch bugs and regressions. In order to do that, we need our logic to be isolated for ease of testing.
We decided to divide the project into smaller projects that we call modules, each with one or two targets: one for the code (a framework target) and sometimes one for the tests. As explained before, the purpose of each module must be clear for everyone. We decided to split them into three categories.
Each scope of business logic is a library. For example, "LibPatient" contains all the logic related to the patient, such as structures describing a patient, patient creation, and fetching from the server. If a library becomes non-cohesive, we need to split it. If two libraries are too coupled, we merge them.
Each flow of the app corresponds to a feature module, which contains everything linked to the UI and navigation.
Finally, some code doesn't match either of these two cases, such as wrappers around SDKs (internal from Apple or external dependencies) or non-business related logic (such as date formatting). This code is extracted into core modules, each with its own specialty. As core modules are too different from one another, there is no real "standard architecture" for them.
A → B means that A can import B
Pbxproj files are difficult to read and are a source of many Git conflicts. In our architecture, each module has its own pbxproj file, which exacerbates the problem. To address this, we have decided to use Tuist to generate these files. By doing so, we can ignore them in Git and avoid this source of stress.
Below is a snippet of a pbxproj file, which is over 1400 lines long, alongside its corresponding Tuist file.
At this point, we know that all business logic should be located in the library modules. The question now is how to organize the files to ensure that the architecture is easily understood.
We found inspiration in the clean architecture.
The main challenge is to be able to easily change an external provider. For example, we use Moya for our API calls, but we want to keep the possibility of replacing it with another system. We also want to test our business logic without testing the providers we use. To achieve this, we decided to expose the functions using an external provider with a protocol. This protocol (and the rest of the code) should not change if we are replacing a dependency. For example, all our API calls are listed in a protocol. We have an implementation of them using Moya, but we could just change the implementation for Alamofire. It also allows us to mock these calls by creating another implementation in the test. Thanks to the dependency injection, we can have either the real implementation in the app, or the mock in the test.
We put this protocol and the “real” implementation in an infra folder. The goal of the other files is not to have anything “leaking” from the infra folder, otherwise, it will make the code depend on our provider. So we have different structures for the infra and the rest of the app (that we will call domain).
For example, when getting the messages’ list from the API, we get a MessageAPI array that needs to be converted to a Message array that can be used outside of the infra folder. This kind of conversion is done in an adapter.
1-3: Make API call
4-5: Convert type to domain structure
8-9: Convert type to realm structure
10-12: Store data in cache
Now for the logic itself, we decided to split it into small bricks for testability. Each brick is an operation, and we split them into two categories:
Splitting into two categories allows us to clearly understand the purpose of a file and what it does not do. We have also observed that this approach helps to reduce the number of bugs.
A good example of split is the following: when we want to fetch some data, store it in cache, and show it in the UI, we split this into two operations: one command that fetches and stores, one query that gets values for the cache. These “small” operations can be unit-tested with infra elements injected in it.
The last thing to notice is that we want to expose as little from the lib as possible, so all commands and queries are internal and exposed in a file. Only this file and the domain are marked as public.
The result can be seen in this scheme.
We have encountered an issue in the library where some operations are synchronous and others are asynchronous. Some queries return a single value, while others return values that may change over time depending on the provider. We need a system that unifies these operations and does not expose their complexity.
Since the application was initially designed to work on iOS 12, we have chosen to use Rx. Synchronous operations can return values with ++code>Single.just++/code>. Our libraries for API calls (Moya) and storage (Realm) have binders for Rx that can handle asynchronous operations. Commands always return ++code>Single<Void>++/code>, and queries return either ++code>Single++/code> or ++code>Observable++/code> depending on the number of values returned.
This system will allow us to have a reactive application that is not bogged down by the complexities of synchronous and asynchronous operations.
As previously mentioned, our initial goal was to ensure that the application was compatible with iOS 12, therefore, Swift UI was not an option.
Additionally, we aimed to split the UI component as much as possible to follow the same logic as before. As such, we eliminated the UIKit option due to the difficulty of modifying storyboards in a team and the challenges of understanding constraints.
Fortunately, we found a library that offers an easy layout while working with iOS 12 devices: Texture. It is based on UIKit but offers a layout engine similar to Flexbox and has an integration with Rx (via RxCocoa_Texture).
As always, we wanted to isolate things as much as possible. To achieve this, we decided to split the navigation and the screens. The navigation is responsible for pushing/presenting view controllers and is the only public part of a feature, which helps limit the interactions between features. Each screen has its own folder.
Despite our efforts to put as much logic as possible in the library, there is still some logic in the UI, such as handling user button presses and which command/query to call.
To ensure that each class has its own responsibility, we created different classes for each screen. This approach also helped us avoid having files with more than 500 lines, which we had set as a limit.
We have decided to base our feature architecture on VIPER. It is a strict architecture, but it follows the principle of splitting content into small files. A common trap when using complex architectures is to have a memory leak. This can occur when a screen is removed from the backstack and some classes have a retain cycle that prevents them from being removed from memory. However, this is not an issue with VIPER.
Nodes are the Texture equivalent of UIView, where each node is a component with its own responsibility. A final node is the “Screen,” which is the assembly of all components.
The ViewController is the container of the screen. It is also from this class that we get the lifecycle changes (such as viewWillAppear). The navigation uses this class to present the screen and also tracks the lifecycle changes (such as viewWillAppear and viewWillDisappear).
The Interactor is a class that interfaces with the libraries, allowing us to expose only the functions required by the screen.
The Router is a class that interfaces with the navigation.
The Presenter is a class that binds everything together. It contains all the Rx PublishRelays (which signal a lifecycle change or a user input) and the BehaviorRelays (which are usually the “state” of the screen: data fetched from the library, data entered by the user, loading state, etc.). Nodes may have a weak reference to the presenter to bind to the Rx elements.
In some cases, we need a struct that is more specific than the domain for a particular screen. In this case, we create a file named “Entity” in which we write the required structure.
The diagram below summarizes the contents of a screen folder.
Finally, let's examine the pros and cons of the architecture we've built.
Pros:
Cons:
We successfully met our constraints by maintaining a codebase that is easy to maintain, has low technical debt, and a very low level of bugs.
We recently redesigned the home screen and patient file, which was made easy by the low coupling between features and between display and logic.
Following clean architecture principles can be challenging at times, but we now see its value more clearly.
After a few months into the project, we raised the minimum iOS version to 13, but we did not change the architecture afterward. We decided it was better to maintain a consistent architecture to avoid issues when transitioning between architectures.
We are aware that VIPER has a bad reputation in the iOS community due to the boilerplate code it requires. However, we appreciate its main advantage: it doesn't generate much technical debt. Furthermore, we can write features just as quickly as we could with another architecture.
If you are starting a new project, we recommend using SwiftUI instead. Texture has poor documentation, and updates are slowing down. Since VIPER and Rx are difficult to use with SwiftUI, we suggest using a different architecture for feature modules.