Let's Make a KMP Project Build: The Scaffold, Line by Line

Fifteen files, no app code, and nothing to run. The first commit of a Kotlin Multiplatform project decides how every module after it gets built — and one line in it prevents a crash that compiles cleanly and passes every test.

Let's Make a KMP Project Build: The Scaffold, Line by Line

The first commit of this project contains no Kotlin Multiplatform code. No composables, no view models, nothing you can launch. Fifteen files: a Gradle wrapper, a version catalog, three convention plugins, and a settings.gradle.kts that includes almost nothing.

It's the least interesting commit in the repository and the one I'd change least. Everything after it is cheap because of it.

This is episode 1 of a series building a real Compose Multiplatform app — the mobile client for this site. You can check out this exact state: git checkout ep-01.

What's actually in it#

Code
build-logic/
  convention/
    src/main/kotlin/
      KmpLibraryConventionPlugin.kt      71 lines
      KmpComposeConventionPlugin.kt      38 lines
      KmpFeatureConventionPlugin.kt      44 lines
      dev/makethiswork/mtw/buildlogic/
        ProjectExtensions.kt             52 lines
gradle/libs.versions.toml              147 lines
settings.gradle.kts                     31 lines

build-logic is an included build — a separate mini Gradle project whose only job is to produce plugins. It compiles before anything else and it cannot depend on the app. That direction matters: the thing that configures your modules must not be configured by them.

1. The version catalog: versions in exactly one place#

libs.versions.toml has [versions], [libraries], [plugins]. The specific numbers are the least interesting part. What matters is that no module ever writes a coordinate string.

Kotlin
implementation(libs.findLibrary("kotlinx-coroutines-core").get())

Bumping coroutines is one line here, not a find-and-replace across twenty build files that misses two of them.

2. The namespace trick#

This is the piece I'd defend hardest.

Kotlin
internal val Project.mtwNamespace: String
    get() = "dev.makethiswork.mtw" + path
        .removePrefix(":")
        .split(":")
        .joinToString(separator = "") { segment ->
            "." + segment.replace("-", "")
        }

:feature:home becomes dev.makethiswork.mtw.feature.home. :core:designsystem becomes dev.makethiswork.mtw.core.designsystem. The Gradle path is the namespace, derived, never typed.

configureAndroid then sets it for every module:

Kotlin
internal fun Project.configureAndroid(extension: CommonExtension<*, *, *, *, *, *>) = with(extension) {
    namespace = mtwNamespace
    compileSdk = libs.version("android-compileSdk").toInt()
    defaultConfig { minSdk = libs.version("android-minSdk").toInt() }
    compileOptions {
        sourceCompatibility = JAVA_VERSION
        targetCompatibility = JAVA_VERSION
    }
}

Never declare an Android namespace by hand. Not as style advice — as a rule with a mechanism behind it. A hand-written namespace is a second source of truth about where a module lives, and second sources of truth drift. Someone moves :feature:search to :feature:discovery, forgets the namespace, and now the package says one thing and the path says another. Nobody notices for months.

3. The line that prevents an invisible crash#

Here's the opening of the base convention plugin:

Kotlin
override fun apply(target: Project): Unit = with(target) {
    pluginManager.apply("org.jetbrains.kotlin.multiplatform")
    pluginManager.apply("com.android.library")
    pluginManager.apply("org.jetbrains.kotlin.plugin.serialization")

That third line is the one worth stopping on.

Type-safe Navigation Compose resolves route serializers reflectively, at runtime. A module that declares an @Serializable route needs the serialization compiler plugin — not just the -json runtime dependency.

Without it:

  • the module compiles
  • your unit tests pass
  • the app builds and installs
  • the screen crashes the first time anyone navigates to it, with Serializer for class 'HomeRoute' is not found

That's the same shape as the bug I wrote about in the callback post: no compiler error, no failing test, and a symptom that arrives far from its cause. The fix is to make it unforgettable rather than to remember it — apply the plugin once, in the base convention, for every module that will ever exist.

4. Three plugins in a ladder#

Code
mtw.kmp.library   →  KMP + Android library, iOS + desktop targets,
                     coroutines, datetime, Koin, Napier, test deps
mtw.kmp.compose   →  the above + Compose Multiplatform
mtw.kmp.feature   →  the above + ViewModel, navigation, Koin-Compose,
                     Coil, core:designsystem

A data module applies library. A design module applies compose. A feature applies feature. That's why a feature's build file is a few lines and declares only the one :data:* module it talks to — everything else is inherited.

Two details in the targets block that will save you a confusing afternoon:

Kotlin
// Compose Multiplatform 1.11 dropped iosX64; Apple silicon simulators only.
iosArm64()
iosSimulatorArm64()

// Named "desktop" so source sets read desktopMain/desktopTest.
jvm("desktop") { ... }

Naming the JVM target "desktop" is cosmetic until you have forty source-set declarations, at which point desktopMain reading as desktopMain rather than jvmMain is worth the five seconds it cost.

5. settings.gradle.kts grows as the series does#

At ep-01, almost nothing is included. ep-02 adds :core:model. ep-03 adds :core:designsystem. Modules join as they're built.

That's not a teaching contrivance — it's what keeps every tagged checkout buildable. A settings.gradle.kts listing twenty modules, nineteen of which don't exist yet, fails to configure at all.

What to run#

There is nothing to launch. No module, no app, no screen. The test is whether Gradle can configure the build at all:

Shell
./gradlew tasks

If it resolves the included build and prints a task list, the foundation holds.

I want to be honest about what happens next, because it's the actual subject of the video: this code had never been compiled when it was written. It was authored in an environment with no access to Maven Central. The first real ./gradlew build is a genuine event, and the version mismatches it surfaces are the episode — not an embarrassment to edit around.

What I'd change#

build-logic has its own settings.gradle.kts and its own repository declarations. It's easy to forget that and then spend twenty minutes wondering why the included build can't resolve the Android Gradle Plugin. Worth reading that file once before you need to.

Three plugins is the right number here, and it's not a law. With two modules this is over-engineering. The ladder pays off somewhere around module six. If you're building something smaller, one convention plugin — or none — is the correct answer, and copying this structure into a two-module app is cargo cult.

JAVA_VERSION and JAVA_TARGET are two constants holding the same idea. One is a JavaVersion, the other an Int, because the two APIs want different types. It works. It's also the kind of small duplication that survives forever because it's never quite annoying enough to fix.

Check yourself#

Check yourself

1Why is the serialization plugin applied to every module rather than only where it's needed?

2What does `:feature:home` become under mtwNamespace?

3Why is build-logic an *included* build rather than a normal module?

4Why does settings.gradle.kts start nearly empty?

5What proves this commit works?

Answers are client-side only — nothing is recorded.

Wrapping up#

Three things worth taking, and only one is about Gradle:

Decide how the project builds before you decide what it does. The cost of this commit is one afternoon. The cost of retrofitting it at module fifteen is a week and a merge conflict with everyone.

Make mistakes impossible rather than memorable. The serialization plugin isn't documented in a README where someone will fail to read it — it's applied centrally, so forgetting it isn't an available option.

Derive what you can. A namespace computed from the path can't drift from the path. Every piece of information typed twice is a future inconsistency with a date on it.

Until then, let's make this work.

Keep reading

More breakdowns

Kotlin Multiplatform

Let's Make a Game Engine Work Inside Compose Multiplatform

No SurfaceView. No OpenGL. No Godot. A 2D game loop running on withFrameNanos, a fixed timestep simulation, and a redraw path that never recomposes — shipping to Android and Desktop from one commonMain.

· 4 min read
Kotlin & Core Concepts

Let's Make SupervisorJob Work: Independent Failure Without Breaking the Tree

One child throws and the whole scope dies. SupervisorJob fixes that — but you probably already have one and didn't know, and the way most people reach for it does nothing except break structured concurrency.

· 4 min read
Android Architecture

Let's Make WorkManager Work: Constraints, Unique Work, and a Retry That Stops

Forty lines that run a job surviving the app being killed. Three decisions inside are worth stealing, and one line is a bug you can ship without noticing — because CancellationException is an Exception.

· 4 min read