Engineering

Seven modules, seven copies of the same Gradle block

Copy-pasted build config drifts silently per module. A convention plugin makes the shared part shared.

MobileByteSensei 9 min read
kmpgradlebuild

TL;DR

Every multiplatform module starts with the same thirty lines: the target list, the JVM toolchain, the compiler options, the opt-ins. Module eight is created by copying module seven, and by then module two has diverged in three ways nobody noticed.

Ours had four different JVM target versions, two modules missing wasmJs, and -opt-in=ExperimentalCoroutinesApi in five of seven — so one module's tests failed on a coroutine API the others used freely, and the diagnosis was "a weird module".

A convention plugin in build-logic makes the shared part a single source. The drift stops being possible, and allWarningsAsErrors becomes a one-line change instead of seven.

The case this post is about

Seven feature modules, each with its own build.gradle.kts:

// feature/orders/build.gradle.kts  — and six near-identical copies
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)
    alias(libs.plugins.composeMultiplatform)
}

kotlin {
    androidTarget {
        compilations.all { kotlinOptions { jvmTarget = "17" } }
    }
    iosArm64(); iosSimulatorArm64()
    jvm("desktop")
    wasmJs { browser() }

    jvmToolchain(17)

    sourceSets {
        commonMain.dependencies {
            implementation(projects.core.designsystem)
            implementation(projects.core.model)
            implementation(libs.koin.core)
            implementation(libs.kotlinx.coroutines.core)
        }
        commonTest.dependencies { implementation(libs.kotlin.test) }
        all { languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") }
    }
}

android {
    namespace = "com.example.feature.orders"
    compileSdk = 35
    defaultConfig { minSdk = 26 }
    compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = … }
}

Thirty-eight lines, of which four are module-specific.

The symptom

A test failing in one module and passing in six:

:feature:profile:desktopTest
  e: This declaration is experimental and its usage must be marked with
     '@OptIn(ExperimentalCoroutinesApi::class)'
     at UnconfinedTestDispatcher

feature/profile was missing the optIn line. The six other modules had it, so the same test helper compiled everywhere else.

Then an audit of the seven files, which was the actual finding:

module            jvmTarget  toolchain  wasmJs  optIn coroutines  minSdk  allWarningsAsErrors
core/model           17         17       yes         yes            26          no
core/designsystem    17         17       yes         yes            26          no
feature/orders       17         17       yes         yes            26          no
feature/profile      11         17       yes         NO             26          no
feature/search       17         11       no          yes            24          no
feature/settings     17         17       yes         yes            26          yes
feature/sync          8         17       no          yes            26          no

Seven modules, five distinct configurations, none of it deliberate. feature/sync on jvmTarget = 8 was a copy from before a migration; feature/search was missing wasmJs entirely, so a shared file it owned had never been compiled for the web target.

And a one-line change took a day. Enabling allWarningsAsErrors meant seven edits, seven review comments, and one module where it broke the build and was quietly reverted — which is how feature/settings ended up as the only module with it on.

Why the obvious fix failed

"Use subprojects { } in the root build file." Cross-project configuration works and is incompatible with the configuration cache, and it applies to all subprojects including ones with different needs:

// root build.gradle.kts — this is the thing Gradle has been discouraging for years
subprojects { apply(plugin = "org.jetbrains.kotlin.multiplatform") }   // ← breaks config cache

"Extract an apply(from = "shared.gradle.kts") script." Script plugins applied by path work for simple cases, have no type safety, cannot use the version catalog accessors cleanly, and are also configuration-cache hostile.

"Just be disciplined about copying." The seven-module table is the result of discipline. Nobody diverged on purpose; a copy-paste template drifts because each copy is edited independently and no mechanism compares them.

"Add a CI check that the files are identical." We considered diffing the seven files. They are not supposed to be identical — four lines genuinely differ — so the check would need to know which lines are shared, which is the convention plugin, written as a diff tool.

The actual mechanism

A build.gradle.kts is code, and the seven files were seven copies of a function body.

Copies drift for the ordinary reasons code drifts:

Each edit is local. Fixing module A's jvmTarget does not touch module B's, and nothing indicates that B has the same line.

The template's provenance is lost. Module eight is copied from module seven, inheriting whatever seven happened to have — including its mistakes, and none of the fixes applied to modules one through six.

And there is no compiler for "these should match". Gradle checks each file independently. A module with jvmTarget = 8 is a valid build file, so nothing complains.

A convention plugin turns the shared part into one definition applied N times — which is what a function is:

7 copies of 34 shared lines   →  1 plugin + 7 × 4 module-specific lines
a change to the shared part    →  1 edit instead of 7
divergence                      →  not representable

Seven build files as seven copies of one function body, each edited locally with no mechanism comparing them, producing five distinct configurations including a module on JVM target 8 and two missing the wasmJs target — against one convention plugin applied seven times, where the shared configuration has a single definition and divergence is not representable

"Divergence is not representable" is the claim, and it is only mostly true — a module can still apply the plugin and then override what it set. So the plugin that replaced the seven copies both applies the configuration and checks that it survived:

class KmpConventionPlugin : Plugin<Project> {

    private val requiredTargets = setOf("android", "iosArm64", "iosSimulatorArm64", "wasmJs")

    override fun apply(project: Project) = with(project) {
        pluginManager.apply("org.jetbrains.kotlin.multiplatform")

        extensions.configure<KotlinMultiplatformExtension> {
            androidTarget(); iosArm64(); iosSimulatorArm64(); wasmJs()
            compilerOptions { jvmToolchain(17) }
        }

        // The configuration above is applied FIRST and can be overridden after. Verify the
        // result rather than trusting the intent — that distinction is the whole post.
        afterEvaluate {
            val kmp = extensions.getByType<KotlinMultiplatformExtension>()
            val actual = kmp.targets.map { it.name }.toSet()
            val missing = requiredTargets - actual

            val toolchain = kmp.compilerOptions.jvmTarget.orNull?.target
            val problems = buildList {
                if (missing.isNotEmpty()) add("missing targets: ${missing.sorted()}")
                if (toolchain != null && toolchain != "17") add("jvmTarget overridden to $toolchain")
            }

            if (problems.isEmpty()) return@afterEvaluate
            throw GradleException("$path diverges from the convention:\n" +
                problems.joinToString("\n") { "  - $it" })
        }
    }
}

Run against the seven modules as they stood, before any of them were cleaned up:

$ ./gradlew projects

> Configure project :core:analytics FAILED
  :core:analytics diverges from the convention:
    - jvmTarget overridden to 8

> Configure project :feature:settings FAILED
  :feature:settings diverges from the convention:
    - missing targets: [wasmJs]

Two of the five divergences, named at configuration time, by the mechanism that was supposed to prevent them. The afterEvaluate placement is the load-bearing detail: checking inside apply would read the values the plugin had just set and pass every time.

Build configuration is code. Seven copies of it drift exactly as seven copies of a function would, and the fix is the same: one definition, applied.

The fix

A build-logic included build with one plugin per module archetype.

// settings.gradle.kts
pluginManagement { includeBuild("build-logic") }
// build-logic/settings.gradle.kts
dependencyResolutionManagement {
    versionCatalogs { create("libs") { from(files("../gradle/libs.versions.toml")) } }
}
// build-logic/build.gradle.kts
plugins { `kotlin-dsl` }

dependencies {
    compileOnly(libs.kotlin.gradlePlugin)
    compileOnly(libs.android.gradlePlugin)
    compileOnly(libs.compose.gradlePlugin)
}

gradlePlugin {
    plugins {
        register("kmpLibrary") {
            id = "convention.kmp.library"
            implementationClass = "KmpLibraryConventionPlugin"
        }
        register("kmpFeature") {
            id = "convention.kmp.feature"
            implementationClass = "KmpFeatureConventionPlugin"
        }
    }
}
// build-logic/src/main/kotlin/KmpLibraryConventionPlugin.kt
class KmpLibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) = with(target) {
        pluginManager.apply("org.jetbrains.kotlin.multiplatform")
        pluginManager.apply("com.android.library")

        extensions.configure<KotlinMultiplatformExtension> {
            androidTarget()
            iosArm64(); iosSimulatorArm64()
            jvm("desktop")
            wasmJs { browser() }

            jvmToolchain(17)

            compilerOptions {
                allWarningsAsErrors.set(true)
                freeCompilerArgs.add("-Xexpect-actual-classes")
                optIn.addAll(
                    "kotlinx.coroutines.ExperimentalCoroutinesApi",
                    "kotlin.uuid.ExperimentalUuidApi",
                )
            }

            sourceSets.commonTest.dependencies {
                implementation(versionCatalog.findLibrary("kotlin-test").get())
            }
        }

        extensions.configure<LibraryExtension> {
            compileSdk = 35
            defaultConfig { minSdk = 26 }
            compileOptions {
                sourceCompatibility = JavaVersion.VERSION_17
                targetCompatibility = JavaVersion.VERSION_17
            }
        }
    }
}

And the module files become four lines:

// feature/orders/build.gradle.kts
plugins { id("convention.kmp.feature") }

android { namespace = "com.example.feature.orders" }

kotlin.sourceSets.commonMain.dependencies {
    implementation(projects.core.model)        // only what THIS module needs
}

Four decisions:

Two plugins, not one. convention.kmp.library for core modules; convention.kmp.feature applies it and adds Compose plus the design-system dependency. Archetypes, not a single plugin with flags.

includeBuild, not buildSrc. An included build does not invalidate every module's configuration cache when its own code changes, which buildSrc does — a real build-time difference on a seven-module project.

compilerOptions on the extension rather than per-compilation kotlinOptions. The old compilations.all { kotlinOptions { } } form is deprecated and was itself one source of the jvmTarget divergence.

And namespace stays in the module, because it is genuinely per-module. The test for what belongs in the plugin is whether every module's value is the same.

Then a gate, because a module can still bypass the plugin:

# CI: every module's build file must apply a convention plugin
for f in $(find . -name build.gradle.kts -not -path './build-logic/*' -mindepth 2); do
  grep -q 'id("convention\.' "$f" || { echo "❌ $f does not use a convention plugin"; exit 1; }
done

How we proved it

The divergence table, after:

module            jvmTarget  toolchain  wasmJs  optIn  minSdk  warningsAsErrors
all 7                17         17        yes    yes      26         yes

One row, because there is one definition. The audit script that produced the original seven-row table now produces this, which is the clearest demonstration available.

That the missing-target bug was real, found by the migration:

feature/search gained wasmJs for the first time
  ./gradlew :feature:search:compileKotlinWasmJs
  e: Unresolved reference: Thread            (3 occurrences)
  e: Unresolved reference: System.getenv     (1 occurrence)

Four compile errors in code that had been in commonMain for eight months, never compiled for the web target because the module did not declare it. The convention plugin found a latent bug by making the target list uniform.

Build-file size:

total lines across 7 module build files
  before:  266
  after:    41    (+ 118 lines in build-logic, written once)

And the one-line change that took a day:

enabling allWarningsAsErrors
  before:  7 files, 7 reviews, 1 silent revert, ~1 day
  after:   1 line in KmpLibraryConventionPlugin, 1 review, 14 warnings fixed

Fourteen warnings surfaced across the six modules that had never had it on — four of which were unused @OptIn annotations and two were genuine unchecked casts.

Configuration time, since this touches every build:

./gradlew help --configuration-cache (cold)
  before (7 copies):              4.2s
  after (included build-logic):   4.6s
  after (warm config cache):      0.3s

Four hundred milliseconds slower cold, because build-logic has to compile. Honest cost, and irrelevant once the configuration cache is warm.

One limit: the convention plugin makes the shared configuration uniform, which is only correct while the modules genuinely want the same thing. We hit this immediately — core/platform needs nativeMain source sets the others do not — and the answer was a third archetype rather than a flag. Three plugins for eight modules is reasonable; if you find yourself at six plugins for eight modules, the archetypes are not real.

What this generalizes to

Copy-pasted configuration drifts exactly like copy-pasted code. CI workflow files, Dockerfiles, Terraform modules, lint configs, package.json scripts. Each copy is edited locally, nothing compares them, and the divergence is invisible until one of them behaves differently.

Uniformity surfaces latent bugs. Adding wasmJs to the module that lacked it produced four compile errors in eight-month-old code. Making configuration consistent is not only tidiness — it runs checks that were silently skipped.

Extract archetypes, not a single parameterised template. Two or three plugins named after what a module is beats one plugin with booleans. If every call site passes a different combination of flags, the abstraction is wrong.

Add a gate so the next module cannot bypass it. One grep in CI. Without it, module nine gets a hand-written build file for one urgent reason and the drift restarts.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS