Modular iOS Part 4: Sharing Configuration Between Modules
If you haven’t read the previous parts in this series, they are:
- Modular iOS Part 1: Strangling the Monolith
- Modular iOS Part 2: Splitting A Workspace into Modules
- Modular iOS Part 3: Configuration & Testing of Modules
Here at TAB we promote modularisation with everything we do. I described the benefits of a modular architecture in the Part 1 of this series: Strangling the iOS Monolith. But one of the implementation challenges is managing how configuration is passed between modules. This is because configuration is typically loaded into the main app bundle, which the dependent modules are unable to access (because it would form a circular dependency).
This article will show how we overcome this challenge at TAB for a modular Xcode workspace; and how a protocol oriented approach could benefit third-party libraries too.
What do I mean by a modular Xcode workspace?
We break our codebases into feature modules, each its own Xcode project within the Xcode workspace.
The image below shows how we might break up the workspace for a grocery shopping app:
Each feature module is completely independent, guaranteeing separation of concerns. There are no dependencies between the feature modules, and the feature modules cannot depend on the main Grocery
project.
The challenge of configuring each module
A codebase will typically have some kind of compile-time configuration. With the modular workspace, we now have several modules that must be configured, but we don’t want to duplicate any configuration. (Duplication is unreliable and fragile, because it would mean making updates in multiple places should any configuration parameters change.)
The app’s target is built from the Grocery
project. This includes AppDelegate.swift
and the main configuration file Config.swift
. We use a tool built internally called configen
to build the configuration into code at compile-time (see related article). But whatever you use, you will be faced with the challenge of how you pass your configuration between Xcode projects.
A naive approach would be to create a corresponding Config
object in each feature module and set the properties on app launch, like so:
But this approach is clearly flawed:
- the feature module’s
Config
properties need to be mutable (i.e.var
) in order to configure them - the feature module’s
Config
properties must be declaredpublic
in order to set them from the main app - you need to ensure that all parameters are setup appropriately
- any parameter setup could easily be removed by mistake in future
- when adding a new parameter, you have to make sure you pass the value through on app launch, which is unnecessary overhead
- it’s not intuitive to newcomers to the codebase
A robust solution
The solution I propose, which we are using in several apps at TAB, is designed to meet the following criteria:
- intuitive, compiler-enforced setup for new parameters
- immutable
Config
in each feature module (i.e. usinglet
properties)Config
properties are internal to the feature module (no need forpublic
) - single line setup for each feature module
- same usage of feature module’s configuration as we’re used to in the main app
The solution is simple. It requires a bit of additional setup in the feature module, but once that is done, extending is simple and robust.
Using the Analytics
module as an example, we define the type of our configuration, in the Config.swift
file in the Analytics
module:
We need to define an initialiser for this type. If we allow this type to be instantiated from another object conforming to a protocol, then we can simply make our main app’s Config
type conform to this protocol. So we define the initialiser as follows:
The protocol AnalyticsConfig
is defined as follows:
This configuration is internal to the module. But we wish to set it up from the containing app’s main project. We want to enforce setup using some object that conforms to our protocol, so we define a module-scope public setup method like so:
The final step is to allow the configuration properties to be accessed in the same way, whether we’re accessing them in the main app, or from within the feature module.
The main app has its own configuration provided by a Config
struct with static properties. (It’s created using Configen, described in this blog post.) The main app references its configuration like so: Config.analyticsKey
. The feature module’s ConfigType
class currently serves no purpose, because its initialiser is private to the Config.swift
file. And its static shared
property is private too. So we make a module-scope, internal computed property named Config
to make usage the same as in the main app (note that it’s Config
with a capital C
to make it the same usage at the call site as the struct in the main app):
Note, we use Realm’s SwiftLint across all of our projects, so we need to disable the check for properties that start with an uppercase letter.
This property means we can access the properties throughout our codebase in the same way, i.e. Config.analyticsKey
.
Configuring on app launch
We need to make our main app’s Config
type conform to the feature modules’ protocols. We do this in a Config+Additions.swift
file, in which we also define some runtime configuration parameters, like so:
We keep the property names the same in the feature modules’ configurations, so that conformance is automatic.
Now setting up the feature modules’ configurations on app launch is a simple one-liner for each module
If this line is removed, then no configuration is passed through to the feature module. And as soon as the feature module’s configuration is accessed, the app will crash with an informative fatal error message.
Adding a new parameter to the feature module
We will in future need to add a new parameter to the future module, for example, if we were to add a buildVersion
parameter to the Analytics
module’s configuration.
We start by adding it to the ConfigType
class, because this is the type we’re accessing when we need to use the configured value.
What’s elegant about this approach is that the compiler will not allow us to make a mistake:
- By adding the instance property, we’re force to update the initialiser
- By updating the initialiser, we’re forced to update the protocol
- By updating the protocol, we’re forced to update any conforming type, i.e. the main app’s
Config
type - By updating the main app’s
Config
type, we’re forced to declare from where this configuration parameter comes (e.g. build time static property, or runtime)
For this example, we would end up extending our main app’s Config
type as follows:
Configuring for unit tests
We need to ensure our module is configured when unit testing, because otherwise we’d see the fatal error. The code in the main app’s AppDelegate.swift
file is not going to execute for each of our feature modules’ test targets.
So we need another way to setup the module’s Config
that will only be executed for unit tests. And we want to avoid having to do this in every test class.
Introducing the TestInitializer
…
In the test target’s Info.plist
file, we set for the NSPrincipalClass
key the value AnalyticsTests.TestInitializer
. This is super simple: the “Principal class” option can be selected from the dropdown in the Plist editor.
We define the TestInitializer
class as follows (we have one in each test target, where necessary):
Conclusion
I’ve shared the full implementation of the feature module’s Config.swift
.
The technique outlined in this article is just another way we can all build robustness into our apps––something we’re really hot on at TAB. It makes extending intuitive for newcomers; and it removes all possibility of making mistakes.
This is a robust technique for dependencies that are internal to a single workspace, but there’s no reason it has to stop there. If third parties used this protocol-oriented approach to configuring their libraries, it would make setup trivial and robust. So we should consider this ourselves next time we make a library, whether open source or closed.
The iOS team at TAB are continually improving our processes to streamline development, so we have more time for the fun stuff. We’re also continually expanding our team.
And you can follow me on Twitter for my rare insights. 😊