Kotlin Multiplatform Project Structure for Integration with Brownfield Applications

Nate Ebel
Engineering at Premise
8 min readOct 6, 2022

--

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.

A diagram showing an Android app, an iOS app, and Shared Code all existing within a single project
A typical greenfield Kotlin Multiplatform project structure

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.

A diagram showing an Android app, an iOS app, and a Kotlin Multiplatform project all existing as separate repos
A Kotlin Multiplatform project structure for integrating with brownfield mobile applications

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:

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.

A diagram showing a single Gradle module within the root Kotlin Multiplatform project repo
A single Gradle module within the root Kotlin Multiplatform project repo

Internally, this module may be organized in several ways.

There could be a single `commonMain` source set that defines shared code for all targets.

A Kotlin Multiplatform Gradle module may contain a single source set for common code

There could be a `commonMain` plus several other source sets-one for each build target allowing for platform-specific code where needed.

A Kotlin Multiplatform Gradle module may contain a several source sets mapping to the various target platforms

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.

A Kotlin Multiplatform project may define multiple sub-projects (Gradle modules) that can contain specific functionality isolated from other modules.

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.

Individual Gradle modules can publish JVM/Android artifacts

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 sharedmodule 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:

Publishing multiple Gradle modules for iOS requires creating a shared wrapper module that depends on all others and is used to generate the output XCFramework

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.

Each feature could be moved into a separate Kotlin Multiplatform repo/project and treated as completely independent projects

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.

--

--