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#
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.
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.
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:
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:
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#
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:
// 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:
./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?
The failure has no compile-time signal and no failing test. Applying it centrally means nobody has to remember it.
2What does `:feature:home` become under mtwNamespace?
Path segments become package segments, with hyphens stripped. The module never declares it.
3Why is build-logic an *included* build rather than a normal module?
Dependency direction. An included build produces the plugins; it can't consume the projects that apply them.
4Why does settings.gradle.kts start nearly empty?
Listing a module that doesn't exist yet fails configuration outright, which would break every earlier checkout.
5What proves this commit works?
No modules exist. If Gradle resolves the included build and prints tasks, the foundation is sound.
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.



