iOS App Modularisation — the Starting Point

Where to start? How to divide the app into modules? What are the options?

Darius Sabaliauskas
Better Programming

--

Photo by Ashkan Forouzani on Unsplash

This post is based on my experience trying to figure out what could be the starting point for modularising an existing iOS app project. I tried to put my thoughts, reasoning, and tips that could help other developers along the way. It might be difficult to get the modularisation ball rolling for an existing project so hope this article will give you some inspiration.

Introduction

Main idea

App modularisation can be approached from different angles depending on use cases and personal preferences. I think that ideally, each module should be independent as much as possible and dependencies that the module needs should be accessed via an interface (aka protocol) not directly, and concrete implementations should be injected via dependency injection. It is not always possible or practical to hide implementation via an interface (aka protocol) but still that should be the aim.

Another important thing is that module should be self-contained, meaning all functionality related to the module should be inside a module and not distributed across different modules. For example, the onboarding module should have the entire flow inside the module, all network requests and response models, also analytics tracking events where networking and analytics are used via an interface (aka protocol) and concrete implementation is injected.

Independent and self-contained feature module | Image by author

I sometimes see that it is done the other way around where the network module has concrete response models and the analytics module has concrete events needed for a specific feature and doing it this way breaks module independence and self-contained idea.

Not independent feature module | Image by Author

The Beginning

There are many reasons why developers want the app to be modular: faster incremental builds, separation of concerns, code changes impact transparency, etc. But sadly, there is no concrete way how to make the app modular and developers have left with a playground of improvisation and strangely shaped puzzle pieces that do not necessarily fit together in the way that was anticipated.

iOS independent code can be extracted to a separate project (or target) that produces a framework. This is good to know but gives little value in trying to figure out how to break up the app into small independent modules.

Where to start?

Usually, there is an already existing project that got a bit too big and there is a need to break it. Here is an example of how it can be approached.

Create a UIComponents framework. There is a lot of colors, custom fonts, UI views, UI building-related extensions, and other that it is needed to build a feature. So, moving part of it as an initial go would be for sure useful. Usually is a good idea to hide implementation behind an interface (aka protocol), but with UI it is not practical, because it is just too much of different small and large pieces.

POC of extracting small features to the module is a good way to get a feeling of how modularisation will play out and identify the main pain points. Pick a small feature that doesn’t have many dependencies on other features and try to extract it so a separate dedicated feature module. The small feature should have a few screens, some networking, and analytics. This POC feature will depend on UIComponents, but still, it will be different UI-related things that are missing and needs to be moved.

Here is a simple action plan that could be followed:

  1. Create a feature framework project and add it as a subproject to the main project
  2. Copy feature-related files to the feature project
  3. Create a TEMP.swift file and copy classes, protocol, etc from the main project till the feature framework builds. Implementation of copied code can be commented out or stubbed. The main purpose of this TEMP file is to get a feature framework to build and have a full picture of what is needed things.
  4. Make feature framework build, by repeating step 3.
  5. Extend UIComponents with missing UI-related code. UI-related copied/stub code from TEMP.swift can be deleted.
  6. Create a Core framework. At this point, UI-related code should be in UIComponents, but networking, analytics, and other code are still in TEMP.swift and needs to be extracted. To create some sort of Core framework to hold those not UI-related dependencies. The key thing here is that networking, analytics, and other needed dependencies would be hidden in the Core framework under interfaces (aka protocols) and injected via dependency injection from Main App.
  7. Replace feature code in Main App with feature framework. At this point, the feature framework should be working and usable in Main App. So feature code should be removed from Main App and replaced with the feature framework code.

The above steps should give you a starting point on the journey into app modularisation. Also, it should result in three Pull Requests:

  • Pull Request: for UIComponents extension. Most of the changes are existing files moving to UIComponents and adding public annotations
  • Pull Request: for the Core framework (networking, analytics, and other not UI-related things). Concrete implementation should be injected from the main app.
  • Pull Request: for feature framework/module which depends on UIComponents and Core frameworks and is used in Main App
Small feature extracted into feature module | Image by Author

Caveats

Hiding implementation behind an interface (aka protocol) should be the direction for those frameworks/modules that will be created even though it is not an easy task. For feature modules, it might be not practical, because of the large code exposed to Main App. But for those foundational modules (core, networking, analytics, etc) it should be the aim.

The analytics interface is small enough, but not all things can be easily abstracted due to concrete implementation provided by the actual analytics tracker that it is used. The biggest pain is in event properties whose values can be Any but it will cause nasty bridging implementation in the main app. But there is a workaround that will seem a bit odd at first glance but will simplify bridging a lot.

analyticsTracker.track(
event: "Complete Payment",
properties:
"Amount": .value(payment.amount), // Double
"Product Name": .value(payment.productName), // String
]
)

Abstraction of event property values:

Networking is a bit of a pain too because ideally, you want to have feature modules self-contained with request and response models and error handling inside the module. To accomplish that some trade-offs need to be done especially in how errors look and how they are handled. To generalize errors, you need to have some sort of generic error object, for example, StandardError which would have (error) code, origin (micro service identifier), and raw response (just in case). This should be sufficient to implement proper error handling in the feature module.

App-Bridging-Header.h is very convenient to add those frameworks/modules in Main App because you don’t need to import them in each place they are used. It helps a lot, especially when moving UI-related code to UIComponents because almost all UI code in the app needs something from UIComponents and it will become nasty pretty quickly to add those imports in many places.

Dependency injection is a good way to provide concrete implementation behind an interface (aka protocol). It can be implemented fairly easily with a service locator and property wrapper.

How injected dependencies are used via property wrapper:

@Injected private var analyticsTracker: AnalyticsTracking
@Injected private var apiWorker: Networking

Example of property wrapper implementation:

Example of service locator:

Static vs Dynamic linking is an important concept that needs to be understood when dealing with Xcode projects. When you create a Framework project in Xcode, you create a dynamically linked Framework, and when you create a Static Library you create a statically linked library.

In practice, dynamic linking means that you will have a single instance of Framework no matter how many times you will add it to different targets. For example, if you have Core modules that are the dynamic framework and are used in many feature modules, you will have a single instance of the Core module shared between all feature modules.

On another hand, if you have a Module that is a static library. it will be each time copied and you will have many instances of it. It might sound that a dynamic framework is an obvious choice, but it is not. The main reason is that the dynamic framework has a penalty on app launch time because additional linking needs to be performed when the app is launching. You should consider if the new Module will actually be used more than once because if you just default to the dynamic Framework option, you will slowly increase app launch time.

Next Steps

When you have the ball rolling with UIComponents (UI code) and Core (not UI code) frameworks you can start to explore how to move forward and how to apply modularisation to a bigger scale in the app. The bigger the feature the harder it is to move it to a module/framework. Also, usually bigger features have more dependencies on other features which just adds complexity, but on other hand, it is interesting to try to solve those big puzzles.

Disclaimer: I didn’t move further than UIComponents and Core frameworks at the point of writing this article, but I want to share some considerations and thoughts that might be valuable. Nevertheless, when things will move forward there should be part 2.

What Is Out There?

From what I saw, usually, there are two sides to how app modularisation is approached — as layered modularisation or as granular modularisation or something in between.

Layered modularisation

The main idea in layered modularisation is that you have some sort of single-core layer that all feature modules are using. Also, the Core layer can have other dependencies, but they are not visible. This allows us to move a lot of code from Main app to the Core pretty fast and improve incremental builds.

But in the end, it ends up in a massive Core (same as a massive ViewController). So, it is usually a short-term solution that requires revisiting and more granularity in long term.

Layered modularisation | Image by Author

Granular modularisation

The main idea of granular modularisation is to have as much granularity as possible. This sounds reasonable, but if it is applied to an extreme, it might end up in an explosion of modules where different modules depend on other modules. And even though it is a highly modular final solution has high coupling because each module has multiple cross dependencies. Especially, if modules are not exposing their functionality via interfaces and dependency injection is not used.

Granular modularisation | Image by Author

I also sometimes see a Core/Shared module in this modularisation approach which contains a collection of small bits of sharable code usually in form of extensions that are individually too small to be separate modules. This is done to prevent duplication of code in feature modules but creates a high coupling of tiny bits in each module that uses the Core/Shared module.

Also, some strange thing (extensions) might land here just because someone thought it is a good idea and can be used in many places even though it is just in one. And because Core/Shared has no concrete purpose (other than the collection of random reusable code) it is hard to control what goes into it.

High-level Vision

It is important to have some high-level vision of what could be an ideal solution in your particular project and then doing some compromises to make it close as possible to ideal. If it is started the other way around, then it is hard to have a feel if modularisation is going in the right direction because the end goal is too abstract.

Here is an example of how a modular app could be imagined. The main app is built from the Feature modules and those Feature modules depend on a set of Foundation modules. Foundation modules like networking, analytics, etc., are exposing just interface (aka protocol) and concrete implementation is injected from Main App.

So, basically those Foundation modules don’t have actual implementation inside them, they just make a standard interface for how networking, analytics, etc. should be done in the Feature modules. Of course, it is not always that ideal. For example, UIComponents is a Foundation module, but it is used in Feature modules directly because it is just practically impossible to hide it under the interface.

Modular app vision | Image by Author

Interdependent features

It might seem that you will have a high-level vision that is perfect and you will go and just do it, but in reality, it is never the case. The most puzzling part is those Feature modules that are used as standalone features in-app and parts of them are used in other features.

For example, the Accounts and Cards feature module is a separate feature in the app for managing your payment accounts and cards, but the account and card picker (part of it) is used in other payment flows as a smart UI component. It is just one example, but in reality, depending on features and connections between them it might be a lot of those cases in-app.

At the time of writing this article, I don’t have a concrete solution for interdependent feature modules, but I hope there will be some guidance in the part 2 article when the modularisation of the app will advance.

Final Thoughts

Probably there is no one size fits all solution for how to modularise the app, but the most important thing is to have a clear high-level vision and your definition of what is a module. It’s also helpful to know what the different types of modules in the app are, and how they depend on each other. Also, there will be a need to do compromises, but at least it should be clear that it is compromise rather than the way it should be done.

Nevertheless, what is your aim? Just make incremental build times faster? Or make feature modules that each cross-functional team could work on with minimal impact on other teams?

Depending on those answers, you can decide how far you want to go with modularisation. Maybe just having a single Core module is enough; maybe having 2–3 is enough and features can live in the main app codebase. Maybe the project is so big and the number of teams is so large that you want to invest time in feature modules.

Nothing is free, and you will need to pay the price either way. If you decide that a non-modular app is good enough at the moment, you will need to pay the price of higher risk to impact other areas across the app and make breaking changes due to the ability to make changes easier. If you decide to make the app modular (to whatever level of modularisation), you will need to pay the price of a bit harder to make changes, but more control of what is impacted.

Example Project

Here is an example project of how that starting point implementation with UIComponents and Core framework looks in reality and how everything is stitched together.

https://github.com/Jamagas/iOS_App_Modularisation_Starting_Point_Example

Example project structure | Image by author

--

--