At ProductDock, our teams are dedicated to developing modular software that allows for the independent building and running of its components. This approach empowers us to take full control of iterating on existing product features and facilitates the creation of robust test suites that leverage the advantages of isolation.
In this article, I’ll discuss how we achieve such lofty goals from the perspective of a native iOS app environment. This article aims to expand the knowledge of less senior engineers and stimulate deeper reflection among more experienced professionals.
It is important to note that the methodologies described in this article are most straightforwardly applicable to products being built from scratch. However, these techniques can also be adapted and utilized for existing products, providing the same benefits. Now, let’s delve into the details and explore the approaches further.
Motivation for building with modularity in mind
When crafting and maintaining native iOS client products using Xcode (the de facto IDE and build system for Apple native apps), it may be tempting to place all of the code into a single, so-called main app target. This may well scale up to a certain point, but most devs working on a project of non-trivial complexity and codebase size will, at a certain point in time, run into the following (non-exhaustive) pain points:
- As the lines of code grow, so does its build time.
- Making trivial changes to a feature’s UI not only requires one to build the whole app (along with its set of dependencies) but also forces the developer to navigate potentially complex flows to see the desired feature on screen.
- The ballooning of build time for the product also applies for build time of the main app’s test target, as placing all tests into a single target leads to the same set of deficiencies.
- SwiftUI Previews are a tremendous productivity boost but they’re also known to be a fragile technology. In order to preserve their stability atomic modules are required instead of monolithic ones.
- Controlling the dependencies of a monolithic target can become increasingly complex and difficult to manage. Without proper attention from the engineering team, dependencies can spread across features in an uncontrolled manner.
The above (and more, as the list of downsides is far from exhaustive) is what leads our iOS engineering teams to build modular from scratch for all but the simplest and most trivial of products.
Exploring the module landscape
Apple provides us with a convenient way of adding a new framework target to the project:
Doing so creates a framework target that exposes an Xcodeproj file. These files are declared in a proprietary file format. They are essentially a bundle (directory) that contains various files and folders related to your project, including build settings, project configurations, source files, resource files, and more.
At the core of an Xcodeproj file is a Pbxproj file which is stored in a plain text format (OpenStep Property List format) and has in its contents encoded the project’s folder and file structure along with the project’s settings and configurations. Having to maintain such Pbxproj files leads to exasperation as they are not meant to be manually edited; rather, Apple’s intention is to expose them via Xcode’s graphical user interface.
They are a maintenance burden, leading to interesting command line tools appearing over the years, which allow us not to commit these files to source control and instead generate them on the fly as a pre- or post-commit hook.
These tools provide so much more than generating project files, but I’ll leave exploring them for another day and, for now, solely focus on first-party solutions to avoid project file bloat.
While SPM is not yet on feature parity with some 3rd party solutions, I’ve found that unless you need capabilities such as exposing only a subset (subspec in CocoaPods terms) of your library to end users or some intricacies which Carthage brings, then Apple’s proposed solution is the way in moving forward.
For most apps from moderate to higher level complexity, it is better to lean on SPM when declaring the feature targets. As SPM is project file agnostic and instead leans on using the filesystem, there is no longer a need to worry about difficult-to-diagnose merge conflicts, and we are free to use Swift in defining our dependency graph and project structure!
Let’s now look at some of our proposed do’s and don’ts when designing an iOS app client with SPM at its center.
Approaches to defining feature modules along with their dependencies
Firstly, there’s no silver bullet nor a set-in-stone blueprint for defining your product’s structure. Each has its own pros and cons.
The two most common modules in modularized apps are UI (or design system) and networking (which abstracts over URLSession).
For the time being, let’s ignore these two modules and consider that we’re building an arbitrary eCommerce app for a clothing retailer. The bare bones screens of an MVP for such an app may be a screen that displays a list of products, a product detail screen, and a checkout screen. Each screen may be represented by a module (or several, as we’ll soon see). Let’s take a closer look at two popular approaches for structuring a modular app with SPM.
One such approach (as seen in the Medium app’s architecture) provides a Package.swift manifest file for each feature separately. A hypothetical package for a feature that lists the products of our eCommerce app may look like the following:
A different approach may be to have a single umbrella package that vends all of the features as separate targets that may be dynamically or statically linked to our main app target. The folder structure would then be as follows:
While there’s certainly no right or wrong answer in going with either of the above, having a single Package.swift over several ones for our use case usually makes the most sense. From my perspective, I prefer the monolithic Package.swift due to the following:
- Unless I’m building an SDK, I do not intend to vendor internal feature libraries of the client’s products.
- The presence of multiple Package.swift files can impede the iterative process of navigating through a collection that encompasses the entire dependency graph. It also restricts the ability to implement custom build settings or compiler flags across all targets.
Choosing either approach does not tell us how these modules communicate or share dependencies between them – for that to be clear, we can consider a lightweight type of module that’s used to define service or API modules. The sole aim of these modules is to provide a public API in the form of an interface to which other frameworks may link freely. A variant of this approach is tackled in depth in a talk by Bruno Rocha, an architect at Spotify.
The main concept behind this idea is that clients should rely only on interface modules rather than concrete feature implementation modules. This approach not only aligns with a principle from SOLID but also provides us with a significant advantage in terms of compile time.
By adhering to this approach, we minimize the need for Xcode to invalidate parts of its build cache when updating files in a transitive dependency. Consequently, the clients of the modified dependency won’t have to be rebuilt from scratch. Since interfaces are typically stable and less prone to change, fewer instances of cache invalidation can be expected, ultimately reducing the time spent waiting for dependencies to build.
In this section, I’ve covered potential project structuring and approaches to modularizing an iOS client via SPM.
But what about utility-like frameworks, which may well represent a core piece of the infrastructure, and does it make sense to introduce such a shared module?
A core-like framework and why we tend to avoid it
Over the years, I’ve been involved in numerous iOS projects that incorporated frameworks labeled as “Core” or “Utils” and followed similar naming conventions. During my experience with such apps, I noticed a recurring pattern: As the product matured and evolved, these modules gradually became a repository for unrelated functionalities.
When using a utility-like framework, there tends to be less consideration given to API design, and it becomes a convenient place to store all sorts of tools. Such an approach poses several issues. Firstly, it leads to a bloated framework that often undergoes changes, causing the cache invalidation problem mentioned earlier. Secondly, the resulting API surface becomes difficult to understand or navigate, lacking clarity and coherence.
These modules also hinder discoverability, as the act of witnessing a feature module linking and importing a Core framework does not provide a clear understanding of why the client is consuming its API. To grasp the purpose of the API, one would need to manually explore the files that import it and examine their contents. Additionally, this practice exposes the client to unrelated functionality that they shouldn’t have been aware of in the first place.
I strongly discourage the use of such frameworks for all but the simplest products. Instead, I propose the following alternatives:
- Introduce dedicated frameworks that cater to specific use cases. For example, consider creating separate modules like “CalendarService” and “CurrencyFormatting” that focus on specific tasks and are easier to maintain. This approach allows clients to address the specific needs they encounter.
- Set up specific SPM targets within your project that provide utility-like functionality exclusively for the feature they serve. Let’s take our hypothetical eCommerce app as an example again. We can create a “CheckoutUtils” module that exposes its API only to the “Checkout” feature module using the SPI (System Programming Interfaces) concept. This ensures that the API remains within its intended boundaries and doesn’t leak outside. Additionally, Swift is introducing a feature called package access modifier that, although limited compared to SPI, will allow us to leverage it for such scenarios.
By adopting these approaches, engineers can create more maintainable and focused frameworks while keeping the API boundaries clear and preventing unnecessary leakage.
And, as usual, when choosing a tool, there’s always a trade-off to consider. Let’s see the bad parts which can be encountered when working with SPM.
It’s not all sun and roses, though. SPM has some major drawbacks and bugs, which may be show-stopping for some teams.
Secondly, SPM is known for leaking transitive dependencies, such as when one links Framework A which depends on Framework B, then this Framework B is also exposed as one can import it into source code directly.
Another undesirable and common situation is the unexpected invalidation and reset of the package cache, which can occur at inconvenient times, such as when making changes to arbitrary source code files. This issue can also arise when switching to a different branch, causing significant disruption to productivity, especially if you don’t have precompiled binaries of your dependencies readily available.
Also, the package manager is yet to support multi-language targets, meaning we’re not allowed to mix Swift and Objective-C nor Swift and C++ code in the same target. This is usually not that big of a concern unless you’re migrating an existing project to SPM, in which case you’ll have to unwind your Swift and Objective-C file dependencies and imports carefully.
Lastly, it’s important to be prepared to rely on traditional 3rd party tools, like CocoaPods, when necessary for dependencies that aren’t available as Swift packages. Until recently, Firebase didn’t offer SPM support due to the complex task of integrating this mixed language dependency into the required format with all the necessary features.
There are many related interesting topics I haven’t covered in this article, such as defining our composition root, exploring techniques in controlling our dependencies, and managing the routing of specific inter-modular screens. There’s also a point to be made in further exploring Tuist, a tool that unlocks even more fine-grained control of the overall project structure.
We will try to cover some of these topics (and more) in one of our future posts, so stay tuned!