KMP for Mobile Native Developer — Part. 4: Modularization

Santiago Mattiauda
11 min readApr 10, 2024

--

Modularization has become increasingly relevant due to the growing complexity of mobile applications and the diversity of platforms. This strategy is essential for improving maintainability, scalability, and code reuse. In this context, Kotlin Multiplatform (KMP) presents itself as a promising solution for the development of mobile applications for various platforms, including Android and iOS. Next, we will explore how to modularize a KMP (Kotlin Multiplatform) project.

Benefits of Modularization in KMP

KMP allows business logic, data models, and components to be shared among various mobile applications, resulting in more efficient development and consistency between application versions.

Modularization in a KMP project offers several significant benefits:

  1. Code Reuse: Independent modules allow components and functionalities to be reused in different parts of the application and on various platforms.
  2. Maintainability: Well-defined modules facilitate an understanding and maintenance of the code. Each module can be developed, tested, and updated independently, speeding up the development process and reducing the possibility of errors.
  3. Scalability: Modularization allows the project to scale more effectively since new modules can be added or modified without affecting the rest of the code.
  4. Decoupling: By separating functionalities into independent modules, coupling between components is reduced, making the code more flexible and easier to extend in the future.

So far, we have theoretically discussed the benefits of modularization. But what strategies could we use to get more out of cross-platform?

Strategies to Modularize a KMP Project

There are several strategies for modularizing a KMP project. Some of the most common are:

  1. Layer Division: This involves organizing the code into modules that represent different layers of the application’s architecture, such as the presentation layer, business logic layer, and data access layer.
  2. Functional Division: This strategy involves grouping code related to a specific functionality into a single module, facilitating its reuse and maintenance.
  3. Platform Division: This strategy involves separating platform-specific code into different modules, while shared code remains in a central module.
  4. Domain Division: This strategy involves organizing the code into modules that represent the different domains of the application, such as the user, authentication, purchases, etc.

The approach we are going to explore next is valid for both monorepos and separate repositories, as the important thing is the configurations of the projects that use KMP.

Modularization in practice

Let’s start from the beginning, when we create a project in KMP, whether it’s an App type or Library type, this will generate a Shared module which will be our shared module for both platforms, in this case Android and iOS.

Pros

  • A simple design with a single module reduces cognitive load. You don’t need to think about where to put your functionality or how to logically divide it into parts.
  • Works very well as a starting point.

Cons

  • Compilation time increases as the shared module grows.
  • This design does not allow having separate features or having dependencies only on the features that the application needs.

So far, we have talked about adding a single KMP module to our Android and iOS applications, even if these applications already exist. But, what happens when our application grows in terms of the number of KMP modules? This could be because the new features that we are adding are more cost-effective to implement in KMP instead of native platform modules (modules in Android, frameworks in iOS), or simply because our company’s teams are starting to adopt this new technology and begin to migrate their workflows to shared KMP modules.

As our shared module expands, it is advisable to divide it into feature modules. This division helps prevent scalability issues related to managing a single module.

Let’s see what the aforementioned would look like in the following image.

As shown in the image, we would have two modules (features) that represent different flows in our application, and one module shared by these features (data). There would also be sub-modules to represent the information handled in our application (in this case, books).

This approach not only offers benefits in terms of separation of responsibilities, but it can also present certain challenges when generating our binaries on each platform (Spoiler alert: mainly on iOS).

Pros

  • Separation of concerns for shared code.
  • Better scalability.

Cons

  • A more complicated setup, including umbrella frame setup.
  • More involves dependency management in modules.

Various Shared Modules

When we work with various shared modules, we encounter differences between platforms to include these modules. The Android application can depend directly on all the feature modules, or only some if necessary. This is because in Android we use Gradle modules for the definition. However, the iOS application can only depend on a framework generated by the Kotlin Multiplatform module. When you use various modules, you need to add an additional module that depends on all the modules you are using, called the Umbrella module. Then, you should configure a framework that contains all the modules, known as the Umbrella framework.

The Android application may depend on the Umbrella module for consistency, or on separate feature modules, as we mentioned earlier. An Umbrella module usually contains useful utility functions and dependency injection configuration code.

Only a few modules can be exported to the Umbrella framework, usually when the framework artifact is consumed as a remote dependency. The main reason for this is to keep the size of the final artifact down, ensuring that auto-generated code is excluded.

A known limitation of the Umbrella framework approach is that the iOS application cannot use just some of the feature modules, as it automatically consumes all of them.

Why do you need an Umbrella framework?

While it is possible to use multiple Kotlin Multiplatform module frameworks on iOS, it is discouraged. When converting a module into a framework, all dependencies are included; if they are duplicated, the app size increases and it can cause conflicts and errors.

Kotlin does not generate common framework dependencies to maintain a compact binary and avoid redundancies. Sharing dependencies is not viable because the Kotlin compiler cannot anticipate the needs of other builds.

The solution is an Umbrella framework, which avoids the duplication of dependencies, optimizes the outcome, and prevents incompatibilities.

💡 For more details on the exact limitations, please consult the TouchLab documentation.

Present various KMP frameworks in detail

Kotlin Multiplatform has a main limitation: the iOS platform cannot access Kotlin modules in a granular way. It generates a single framework that contains all exported Kotlin classes. Although it is possible to generate multiple frameworks, this implies a larger binary and overhead due to duplicate classes from the standard Kotlin library.

As if that were not enough, all shared dependencies in the Kotlin modules will also be duplicated.

In our example we would have something like this:

The Book entity of the Home framework and the Book entity of the Checkout framework would represent the same entity in the data module. However, in our iOS application, these would be considered as two different entities in two different contexts, that is, they would be duplicate entities.

The main problem lies in that, on iOS, common classes in each framework are considered different. This implies that, for example, a shared data structure cannot be interchangeably used between different frameworks.

Let’s go to the code

Let’s see an example of the aforementioned in code. For this, we are going to generate a KMP project with the Home, Checkout and Data modules, as we saw in the examples.

In this first example, we will use the separation by framework in iOS and by modules in Android. Here, the interesting part is to see how our objects behave with multiple frameworks in iOS.

For the example, we will have the following class diagram:

As we can see, we have the ProcessCheckout class in the :checkout module and GetAll in the :home module, which depend on the BookRepository from the :data:book module.

Our project level structure would be as follows:

And as we see in the diagram below,

The Android and iOS applications will be responsible for orchestrating the modules (frameworks for iOS) at the level of native code (Kotlin or Swift).

💡 This approach of including multiple modules/frameworks generated from KMP modules may be valid for and generating adoption of the technology in existing projects. The important thing at this point if we adopt this path is to take into account the frictions that it generates for us, which we mentioned earlier.

Let’s start with Android

Our application on Android will have the following configuration at the dependency level

dependencies {
//Checkout
implementation(projects.checkout)
//Home
implementation(projects.home)
//Data
implementation(projects.data)

implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.androidx.activity.compose)
debugImplementation(libs.compose.ui.tooling)
}

We will include the modules: :checkout, :home and :data in our build.gradle.kts file.

Let’s see an example of how to use the classes from different modules and what happens with the references from the :data module, a module shared between both feature modules. For this example, we will use the definition of a ViewModel that the ProcessCheckout and GetAll classes will instantiate with a common dependency, BookRepository.

If we observe the imports, we will notice that BookRepository comes from data and is the same dependency used by home and checkout in their classes. The same happens with the book entity, so we do not have duplicate dependencies.

Let’s look at the same example but in iOS to verify that we do not present the same behavior.

Just like in Android, let’s start by setting up our Podfile with the :home and :checkout modules in our iOS app.

Let’s write our previous example, but this time in Swift.

import Foundation
import home
import checkout

class MainViewModel {

let bookRepository = DataBookRepository()
let processCheckout = ProcessCheckout(repository: bookRepository)
let getAll = GetAll(repository: bookRepository)


func checkout() {
let books = getAll.invoke()
let currentBook = books.first

processCheckout.invoke(book: currentBook)
}
}

Just like the previous example in Kotlin, we would have an instance of BookRepository that we will reuse in ProcessCheckout of the checkout framework and GetAll of the home framework. But, let’s look at the first inconvenience in the following image.

💡 The compiler renames the BookRepository class to DataBookRepository for use in Swift.

When trying to generate an instance of the DataBookRepository class, the compiler throws an “Ambiguous use of init” error. This means that it can’t resolve the class constructor because, in our case, as we’ll see in the following two images, we have two references with the same name.

We remove the error line and continue with the implementation. We’ll find that the previous error was due to having the DataBookRepository class defined both in the checkout framework and in the home framework.

Pods>Development Pods>checkout>Frameworks>checkout.framework>Headers>checkout.h
Pods>Development Pods>home>Frameworks>home.framework>Headers>home.h

Thus noting the duplication of classes, the same happens with the Book entity.

Implementing the Umbrella module

Let’s solve our problem using the Umbrella module. In this case, we will generate a :shared module that will include our two features :checkout and :home, allowing us to include a single framework in iOS.

With this change, we will have the following project level structure.

Inside shared, we will define the dependencies we mentioned earlier. To do this, in our build.gradle.kts file, we will have the following:

kotlin {
//...

cocoapods {
summary = "Some description for the Shared Module"
homepage = "Link to the Shared Module homepage"
version = "1.0"
ios.deploymentTarget = "16.0"
podfile = project.file("../iosApp/Podfile")
framework {
baseName = "shared"
export(project(":home"))
export(project(":checkout"))
}
}

sourceSets {
commonMain.dependencies {
api(project(":home"))
api(project(":checkout"))
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}

In the dependencies of commonMain we include the modules of :home and :checkout, followed by the definition of our Cocoapods framework with the respective exports, so that we can use the dependencies from our iOS code.

Now, our Podfile would look like this with our shared module.

To wrap up the example, let’s see how the code that was previously incompatible due to the conflict between the :home and :checkout frameworks looks.

As we can see, we can now reuse the BookRepository instance in the ProcessCheckout and GetAll classes, since this type definition is the same, regardless of the module it belongs to. The same happens with the Book entity, as we see in the checkout function.

Compilation

One of the main benefits of modularizing a project is that it can reduce compilation times, as unchanged modules can be stored in cache. In theory, this sounds good, and the Android application builds much faster when only some modules change.

However, the problem arises when building the Kotlin/Native part of KMP, due to the gradle tasks linkDebugFrameworkIos and linkReleaseFrameworkIos. These tasks take a long time, regardless of whether changes are made in one or several modules.

Even so, I have noticed that it is not necessary to build the shared module on each occasion. Making small changes, building the Android application or running iOS tests in the modified module (perhaps tests in a different module also work) results in the changes being present in the iOS application.

Although I don’t have an exact science for this, it definitely speeds up the feedback cycle when building the iOS application. However, I still think that automated tests for the KMP logic will result in a faster and more efficient feedback cycle, compared to running the Android / iOS application on each change.

💡 compileKotlinIosArm64 and compileKotlinIosX64 greatly speed up the compilation time.

Conclusion

For native mobile development with Kotlin Multiplatform (KMP), modularization is crucial. As KMP modules grow in size, it is advisable to split them into feature modules to prevent scalability issues. However, working with multiple shared modules can pose challenges, especially on iOS, which can only rely on one framework generated by the KMP module. To solve this, an additional module called the Umbrella module that depends on all the used modules is required. Although this strategy can optimize the outcome and avoid incompatibilities, it can also pose challenges, such as the inability of the iOS application to use only some of the feature modules.

References

Serie: KMP for Mobile Native Developer

--

--