Kotlin Multiplatform Project Structure for Integration with Brownfield Applications
By Nate Ebel, Staff Software Engineer
Most Kotlin Multiplatform Mobile sample projects follow the same project structure — an Android project directory, an iOS project directory, and a common module shared between them in a single mono repo.
This makes sense for a greenfield project.
I think this also makes a lot of sense for sample projects since it reduces project complexity and setup times; both of which are tremendously valuable in learning content for beginners.
However, this greenfield monorepo world is not the world that most teams are living in when evaluating whether or not Kotlin Multiplatform is a viable code sharing solution.
When our team began evaluating Kotlin Multiplatform, we knew we needed a solution that did not require us to merge our separate Android and iOS repos into a new monorepo structure. Instead, we took the approach of treating our shared code as an internal, first-party library that would be published to, and consumed from, some internal location.
we took the approach of treating our shared code as an internal, first-party library
In this paradigm, we end up with three separate repos- an Android repo, an iOS repo and our shared code in a new Kotlin Multiplatform repo.
In this post, we’ll detail our exploration of both a single-feature project structure, and a structure that has supported us as we adopt Kotlin Multiplatform for additional functional areas over time.
This is a part of an ongoing series on our usage of Kotlin Multiplatform at Premise:
- Part 1: Kotlin Multiplatform at Premise
- Part 2: Kotlin Multiplatform Project Structure for Integrating with Brownfield Applications
- Part 3: Building a CI Pipeline for Kotlin Multiplatform Mobile Using GitHub Actions
- Part 4: Publishing Kotlin Multiplatform Swift Packages to Google Cloud Storage
- Part 5: Generating BuildConfig Files for a Kotlin Multiplatform Library — Coming Soon
- Part 6: Optimizing Local Build Times for Kotlin Multiplatform Mobile Projects — Coming Soon
Single Feature Module Structure
How do you structure a Kotlin Multiplatform project with a single module?
The most straightforward project structure for this first-party library integration model is to have a single Gradle module within the Kotlin Multiplatform project. This module contains all shared code.
Internally, this module may be organized in several ways.
There could be a single `commonMain` source set that defines shared code for all targets.
There could be a `commonMain` plus several other source sets-one for each build target allowing for platform-specific code where needed.
And within any of these source sets, we are free to define whatever package structure we may like.
Build Output
With this structure, our build would generate a single output artifact for each target. That artifact would contain all the code exposed to a given platform.
If the project targets Android and iOS, then we would end up with
- a .jar or .aar
- an XCFramework
Publishing JVM/Android Artifacts
To generate Maven publications for our Android and JVM targets, we can add the maven-publish
plugin.
// shared/build.gradle.ktsplugins {
id("maven-publish")
}kotlin {
android()
}
And to ultimately publish those artifacts to an internal Artifactory server we configure something like the following:
// shared/build.gradle.kts...artifactory {
setContextUrl(properties["ARTIFACTORY_URL"])
publish(delegateClosureOf<org.jfrog.gradle.plugin.artifactory.dsl.PublisherConfig> {
repository(delegateClosureOf<org.jfrog.gradle.plugin.artifactory.dsl.DoubleDelegateWrapper> {
setProperty("repoKey", properties["ARTIFACTORY_REPO"])
setProperty("username", properties["ARTIFACTORY_USERNAME"])
setProperty("password", properties["ARTIFACTORY_PASSWORD"])
setProperty("maven", true)
})
defaults(delegateClosureOf<groovy.lang.GroovyObject> {
invokeMethod("publications", arrayOf("androidRelease", "kotlinMultiplatform"))
})
})
}
Building iOS Artifacts
For iOS, building the XCFramework is pretty straightforward in the single-module approach.
We can configure our iOS targets to generate an XCFramework in our Gradle buildscript.
// shared/build.gradle.kts
...kotlin {
val xcf = XCFramework()
ios {
binaries.framework {
baseName = "shared"
xcf.add(this)
}
}
}
Adding this XCFramework()
configuration will result in an assembleXCFramework
task being created that will generate the XCFramework for us.
Consuming this XCFramework can be done in several ways. Our team publishes the framework as a Swift Package. That process is a bit more involved, and is described in more detail in this post:
https://engineering.premise.com/kotlin-multiplatforrm-github-actions-workflow-3e5e0fcb7081
Benefits to a single-module approach
This approach is the easiest to setup as it requires the least amount of Gradle configuration. A single module also reduces any cognitive load in considering whether or not to add new code in a new module or not; everything just goes into the same place (maybe separated by package).
A single module also means a single output artifact per platform, which means fewer artifacts to version, publish, and consume.
Drawbacks to a single-module approach
Putting all code into a single module can lead to unintended dependencies between individual components as the lines between functional areas blur. A single module can also have a negative impact on build times as the project grows and any change requires the whole project to be rebuilt.
Organizing around a single module also means likely having a single library version for everything. This may, or may not, be a problem based on how you envision your project growing and your team working on it. If there’s a chance that separate functional areas may need to develop at a different pace, then a single module is not the ideal structure.
And finally, if all the shared code exists within, and is exported to, a single build output per target, there’s no way to consume only specific functional areas. For example, if one project needed both DTOs and Analytics and another only needed Analytics there would be no way to separate the use cases; both projects would receive all shared code at all times. As we’ll see in the next section, this is likely to be the case for iOS no matter what, but on the JVM/Android side we have more options.
Multi-Feature Module Structure
As we evaluated Kotlin Multiplatform, we initially started with only a shared analytics use case. However, we also envisioned an N+1 world in which we wanted to add more functional areas to our shared code.
Imagining that we might want multiple features such as analytics, dtos, deeplinking, etc lead us to explore a project structure in which each functional area could be a separate Gradle module within our Kotlin Multiplatform project.
This approach is straightforward for JVM and Android targets.
Each functional module may define it’s own publishing config in the same way as a single-module approach would. And ultimately those publishing configs will be used to generate a .jar or .aar for each module.
However, on the iOS side there is additional project configuration needed to support individual functional modules. A current limitation of the Kotlin Multiplatform tooling is that a single project can only produce a single output XCFramework.
This means we can’t generate one XCFramework per module as we can for JVM/Android targets.
Instead, we must define another Gradle module to act as a wrapper. This shared
module defines each functional module as a dependency and then configures the output XCFramework to include them all.
// shared/build.gradle.ktsplugins {
kotlin("multiplatform")
}kotlin {
val xcf = XCFramework(rootProject.name)
val nativeTargets = listOf(iosSimulatorArm64(), iosArm64())
nativeTargets.forEach { target ->
target.binaries.framework {
baseName = rootProject.name
xcf.add(this)
export(project(":analytics"))
export(project(":network"))
export(project(":data"))
transitiveExport = true
}
}
sourceSets {
val iosSimulatorArm64Main by getting
val iosArm64Main by getting
val iosMain by creating {
iosSimulatorArm64Main.dependsOn(this)
iosArm64Main.dependsOn(this)
}
}
}
The resulting project structure looks something like the following:
Why use separate modules?
Separating functional areas into their own benefits has several benefits in our estimation:
- separation of concerns
- improved build times when changing a single module
- separate output artifacts for JVM/Android
We felt these attributes would scale well if we continued to invest in KMP. In particular, it meant that we could integrate functional modules on a case-by-case basis. For example, our Android app could consume every shared feature, while our mobile-proxy backend service could consume only the Analytics module if desired.
Drawbacks to a multi-module approach?
The main drawback here is probably the added complexity.
We must configure an extra wrapper module for the iOS output. We must add additional JVM/Android publication configurations for each functional module. We must work harder to keep dependency versions synced across each module. There’s extra work involved in adding a new functional area to the project due to the cost of setting up a new module.
All of these are very solvable problems, but in our experience, has required members of the team that are comfortable with non-trivial Gradle configuration.
Multi-Project Structure
We’ve focused the discussion so far on how to structure a single project for one, or more, output artifacts. However, nothing is stopping us from just creating separate KMP projects altogether.
This would provide a means of publishing/consuming separate XCFrameworks for iOS projects.
For a team with a single iOS app, this probably adds extra complexity for zero gain. But if your team maintains multiple iOS projects, having shared functionality split into separate artifacts could be beneficial.
For JVM/Android applications, this approach would likely provide little benefit to consumers. However, if development of individual functional areas was owned by separate teams/individuals, then separate projects could increase development velocity if those projects are not interdependent.
Closing Thoughts
Choosing the right project structure for you applications will depend on the applications themselves, your team structures, what code you’re trying to share, etc.
However, if you’re integrating your Kotlin Multiplatform code into existing applications, the chances are high that you’ll want to structure your KMP project using one of the approaches detailed above.
We’ve maintained a single KMP project, multi-module structure for over a year and have found it scaling well as we integrate additional functional areas and onboarding more developers.
Diving deeper into how we are leveraging Kotlin Multiplatform
Once you’ve settled on a project structure, you’ll likely move on to new challenges such as how to build your Kotlin Multiplatform project in CI and how to integrate your XCFramework into your iOS project.
Premise is constantly looking to hire top-tier engineering talent to help us improve our front-end, mobile offerings, data and cloud infrastructure. Come find out why we’re consistently named among the top places to work in Silicon Valley by visiting our Careers page.