Gradle Configuration, the Multiplatform DSL and Compatibility

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Every Kotlin Multiplatform (KMP) project is, first of all, a Gradle build. The Kotlin Multiplatform Gradle plugin (org.jetbrains.kotlin.multiplatform) adds one extension, kotlin \{ …​ \}, to each shared module, and everything the earlier pages described — targets, compilations, source sets and the iOS framework — is configured inside it. This page is the working reference for that build: the files you create, the DSL you write in them, the flags that keep them fast, and the version matrix that decides whether a given combination of tools works at all.

The baseline is Kotlin 2.4.20 (Kotlin Multiplatform Gradle plugin 2.4.20), Compose Multiplatform (CMP) 1.12.1, Android Gradle Plugin (AGP) 9.4.1 and Xcode 26.4, with Gradle 9.8.0 as the wrapper version. Examples are slices of TrailMate Everywhere, the multiplatform version of the Android section’s TrailMate app (package com.example.trailmate), with the modules sharedLogic, sharedUI, androidApp, desktopApp and webApp, plus the Xcode project iosApp. For the module layout see Project Structure and Source Sets; for how a native Android build looks without KMP see Android Project Structure and Gradle. The Kotlin language itself, and Gradle basics in the Kotlin context, are covered in Kotlin Build and Tooling and are not repeated here.

settings.gradle.kts

The settings script is evaluated before any module and decides where Gradle looks for plugins and libraries. Centralizing repositories in dependencyResolutionManagement with FAIL_ON_PROJECT_REPOS means a module cannot quietly add its own repository, which keeps resolution reproducible.

// settings.gradle.kts
rootProject.name = "TrailMateEverywhere"

// Gives modules type-safe project accessors: projects.sharedLogic instead of project(":sharedLogic")
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")

pluginManagement {
    repositories {
        google {
            content {
                includeGroupAndSubgroups("com.android")
                includeGroupAndSubgroups("com.google")
                includeGroupAndSubgroups("androidx")
            }
        }
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google {
            content {
                includeGroupAndSubgroups("com.android")
                includeGroupAndSubgroups("com.google")
                includeGroupAndSubgroups("androidx")
            }
        }
        mavenCentral()
    }
}

include(":sharedLogic")
include(":sharedUI")
include(":androidApp")
include(":desktopApp")
include(":webApp")

The Kotlin Multiplatform and Compose Multiplatform plugins come from the Gradle Plugin Portal and Maven Central, AGP and AndroidX libraries from Google Maven. JetBrains' Compose artifacts (for example org.jetbrains.compose.components:components-resources) are published to Maven Central, so no extra repository is needed in 2026. A common mistake is to declare google() without the content filter and then wonder why a multiplatform library resolves from the wrong place; filtering Google’s repository to the groups it owns avoids it.

The version catalog

Legacy KMP builds kept versions in a buildSrc object named Versions. The replacement is a version catalog: a TOML file that Gradle turns into type-safe accessors (libs.ktor.client.core, libs.plugins.kotlinMultiplatform). It is the single source of truth for versions, and Android Studio, IntelliJ IDEA, Renovate and Dependabot all understand it.

# gradle/libs.versions.toml
[versions]
kotlin = "2.4.20"
agp = "9.4.1"
compose-multiplatform = "1.12.1"
coroutines = "1.11.0"
serialization = "1.11.0"
datetime = "0.8.0"
ktor = "3.6.0"
sqldelight = "2.4.0"
koin = "4.2.2"
androidx-lifecycle = "2.11.0"
kermit = "2.2.0"
coil = "3.6.3"
turbine = "1.2.1"
mokkery = "3.5.0"
vanniktech-publish = "0.37.0"
dokka = "2.2.0"

[libraries]
# kotlinx
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }

# Ktor
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }

# SQLDelight
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }

# Koin and lifecycle
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }

# Misc
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
turbine = { module = "app.cash.turbine:turbine", version = "1.2.1" }

[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
vanniktechPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktech-publish" }
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }

Three details worth noticing. The Kotlin, Compose-compiler and serialization plugins all use version.ref = "kotlin": the Compose compiler plugin (org.jetbrains.kotlin.plugin.compose) is versioned with Kotlin, while the org.jetbrains.compose Gradle plugin carries the CMP version. Both com.android.application and com.android.kotlin.multiplatform.library share the agp version, because they are two entry points of one plugin family. Finally, hyphens in alias names become dots in the accessor, so ktor-client-core is libs.ktor.client.core.

The root build script applies each plugin once with apply false, so the modules do not each load their own copy:

// build.gradle.kts
plugins {
    alias(libs.plugins.kotlinMultiplatform) apply false
    alias(libs.plugins.kotlinSerialization) apply false
    alias(libs.plugins.composeCompiler) apply false
    alias(libs.plugins.composeMultiplatform) apply false
    alias(libs.plugins.androidApplication) apply false
    alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
    alias(libs.plugins.sqldelight) apply false
}

The kotlin block

A module’s build.gradle.kts applies the plugins and then configures the kotlin \{ \} extension. The top-level blocks documented in the DSL reference are:

Block What it configures

<targetName> (for example jvm(), iosArm64())

Declares one target. A target compiles, tests and packages code for one platform.

targets

The collection of every declared target, for code that must loop over them.

sourceSets

Configures predefined source sets and declares custom ones.

compilerOptions

Extension-level compiler options, used as defaults for every target and shared source set.

dependencies

Common dependencies. Experimental; see below.

Declaring targets

// sharedLogic/build.gradle.kts
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidKotlinMultiplatformLibrary)
    alias(libs.plugins.kotlinSerialization)
    alias(libs.plugins.sqldelight)
}

kotlin {
    // Android: the single-variant plugin is configured here, not in an android {} block at the top level
    android {
        namespace = "com.example.trailmate.shared"
        compileSdk = 37
        minSdk = 26
    }

    // Desktop: a JVM target with a custom name
    jvm("desktop") {
        compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
    }

    // iOS: explicit targets, no ios() shortcut
    listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "SharedLogic"
            isStatic = true
        }
    }

    // Web: Kotlin/Wasm (Beta)
    wasmJs { browser() }
}

Rules the DSL enforces: a project can declare one Android target per Gradle subproject; jvm("desktop") gives the target a name (and so the source set desktopMain and the task prefix desktop) while a plain jvm() uses jvm; a target the current host cannot build, such as iosArm64() on Linux, is ignored and not published, so the same build script is valid on every machine. Kotlin/Wasm has the target wasmJs for JavaScript runtimes and wasmWasi for the WASI interface; js() selects browser() or nodejs(). Which Kotlin/Native targets exist, and their tiers, are covered in How KMP Compiles.

Type-safe source sets

Source sets are accessed through generated accessors named after the source set. This replaced the older val commonMain by getting pattern, which is legacy and unnecessary.

// sharedLogic/build.gradle.kts (continued)
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.kotlinx.coroutines.core)
            implementation(libs.kotlinx.serialization.json)
            implementation(libs.kotlinx.datetime)
            implementation(libs.ktor.client.core)
            implementation(libs.ktor.client.content.negotiation)
            implementation(libs.ktor.serialization.kotlinx.json)
            implementation(libs.sqldelight.runtime)
            implementation(libs.sqldelight.coroutines)
            implementation(libs.koin.core)
            implementation(libs.kermit)
            api(libs.androidx.lifecycle.viewmodel)   // part of the public API: exposed to iOS
        }
        commonTest.dependencies {
            implementation(kotlin("test"))
            implementation(libs.kotlinx.coroutines.test)
            implementation(libs.ktor.client.mock)
            implementation(libs.turbine)
        }
        androidMain.dependencies {
            implementation(libs.ktor.client.okhttp)
            implementation(libs.sqldelight.android.driver)
        }
        iosMain.dependencies {
            implementation(libs.ktor.client.darwin)
            implementation(libs.sqldelight.native.driver)
        }
        val desktopMain by getting {   // custom target name: no generated accessor in this form
            dependencies {
                implementation(libs.ktor.client.okhttp)
                implementation(libs.sqldelight.sqlite.driver)
                implementation(libs.kotlinx.coroutines.swing)
            }
        }
        wasmJsMain.dependencies {
            implementation(libs.ktor.client.js)
        }
    }
}

The dependency configurations are the familiar Gradle ones, scoped per source set. api makes a dependency part of the module’s public surface; implementation hides it; compileOnly and runtimeOnly exist for the rare cases that need them. In an iOS project the distinction matters twice over: only api dependencies can be exported into the Swift-visible framework (see Native binaries), and a dependency that is only implementation in sharedLogic is invisible to Swift even though the app uses it at runtime.

The desktopMain by getting form is shown on purpose: accessors such as androidMain and iosMain are generated for names Gradle can derive from the plugin’s default hierarchy, but a source set named after a custom target (jvm("desktop")) is addressed with getting or named("desktopMain"). The most common cause of "unresolved reference" in a new module is putting a library in the wrong source set — for example a Ktor engine that only exists for one platform in commonMain. The fix is to move it to the platform source set and keep only the engine-independent ktor-client-core in commonMain.

Compiler options

compilerOptions is a typed, lazy (Property-based) configuration object. It exists at three levels, and the more specific level wins:

Level Where Applies to

Extension

kotlin \{ compilerOptions \{ \} \}

All targets and all shared source sets (defaults).

Target

kotlin \{ jvm \{ compilerOptions \{ \} \} \}

Every compilation of that target; overrides the extension level.

Compilation / task

compilations.configureEach \{ compileTaskProvider.configure \{ compilerOptions \{ \} \} \}

One compile task, for example only main and not test.

// sharedLogic/build.gradle.kts (continued)
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion

kotlin {
    // Extension level: shared defaults
    compilerOptions {
        languageVersion.set(KotlinVersion.KOTLIN_2_4)
        apiVersion.set(KotlinVersion.KOTLIN_2_4)
        allWarningsAsErrors.set(providers.gradleProperty("trailmate.warningsAsErrors").map(String::toBoolean).orElse(false))
        optIn.add("kotlin.time.ExperimentalTime")
        freeCompilerArgs.add("-Xexpect-actual-classes")   // expect/actual classes are Beta
    }

    // Target level: overrides the extension level for the desktop target only
    jvm("desktop") {
        compilerOptions {
            jvmTarget.set(JvmTarget.JVM_17)
            freeCompilerArgs.add("-Xjdk-release=17")
        }
    }

    // Compilation level: stricter settings for production code only, not tests
    targets.configureEach {
        compilations.configureEach {
            if (name == "main") {
                compileTaskProvider.configure {
                    compilerOptions.allWarningsAsErrors.set(true)
                }
            }
        }
    }
}

Options are set with .set() or .add(), not with assignment to a plain field, because they are Gradle Property and ListProperty objects. The older kotlinOptions \{ \} block was removed and is a frequent leftover in copied build scripts. A separate languageSettings \{ \} block exists per source set, but it is meant only for settings that apply to a single shared source set; everything else belongs in compilerOptions.

Top-level dependencies (Experimental)

The Kotlin Gradle plugin also has a top-level dependencies \{ \} block inside kotlin \{ \}. Dependencies declared there behave as if they were added to commonMain or commonTest. It is Experimental, and requires an @OptIn(ExperimentalKotlinGradlePluginApi::class) on the block. Platform-specific dependencies still go in the sourceSets \{ \} block of the matching target.

// sharedUI/build.gradle.kts
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi

kotlin {
    @OptIn(ExperimentalKotlinGradlePluginApi::class)
    dependencies {
        implementation(libs.kotlinx.coroutines.core)
        implementation(projects.sharedLogic)
    }

    sourceSets {
        androidMain.dependencies {
            implementation(libs.coil.compose)   // platform-specific stays in sourceSets
        }
    }
}

Prefer the stable sourceSets \{ commonMain.dependencies \{ \} \} form in shared libraries and in code you expect to keep through several Kotlin releases; the Experimental block is reasonable in an application that upgrades Kotlin on every release and wants a flatter build file. Its shape may change.

Configuring compilations

A compilation is a pairing of a target with a set of source sets that are compiled together into one output. Every target gets a main and a test compilation by default; the Android target adds host and device test compilations when enabled. Custom compilations exist for cases such as integration tests or benchmarks. A compilation has a default source set (desktopMain for desktop/main) and may pull more source sets in through associateWith and dependsOn.

// sharedLogic/build.gradle.kts (continued)
kotlin {
    jvm("desktop") {
        // A custom compilation that can see the internals of "main", like tests do
        val main by compilations.getting
        val integrationTest by compilations.creating {
            associateWith(main)
            defaultSourceSet {
                dependencies {
                    implementation(kotlin("test"))
                    implementation(libs.ktor.client.mock)
                }
            }
        }
        // Wire it into the check lifecycle with a test task
        testRuns.create("integration") {
            setExecutionSourceFrom(integrationTest)
        }
    }

    iosArm64 {
        compilations.getByName("main") {
            // Compilation-specific cinterop for platform libraries or .def files
            cinterops.create("trailmateLocation") {
                definitionFile.set(project.file("src/nativeInterop/cinterop/location.def"))
            }
        }
    }
}

associateWith(main) is what makes a custom compilation see internal declarations of main, exactly how the default test compilation is associated. Without it, the new compilation sees only public API. Beyond that, two rules cover most needs. First, compilation-level compiler options are changed through compileTaskProvider, as in the previous section. Second, the compile output of a compilation can be reached as compilation.output.classesDirs (JVM) or the klib task for native; build scripts that need a path should ask the task instead of hard-coding build/classes.

Native binaries

By default a Kotlin/Native target compiles to a .klib: a library that other Kotlin/Native modules can consume but that nothing else can run. The KMP plugin creates no production binaries by default — the only binary is a debug test executable. To produce something an iOS app, a Linux service or a C program can use, declare it in the target’s binaries collection.

Factory method Binary kind Available for

executable

Product executable

All native targets

sharedLib

Shared native library (.so, .dylib, .dll)

All native targets

staticLib

Static native library (.a)

All native targets

framework

Objective-C/Swift framework

macOS, iOS, watchOS and tvOS targets only

test

Test executable

All native targets

Each factory takes an optional name prefix and a list of build types (DEBUG, RELEASE). Binaries are named <optional-prefix><build-type><binary-kind>, such as releaseFramework or testDebugExecutable, and Gradle creates link tasks with matching names (linkReleaseFrameworkIosArm64).

Framework, isStatic and baseName

// sharedLogic/build.gradle.kts (continued)
kotlin {
    listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "SharedLogic"          // the Swift module name: import SharedLogic
            isStatic = true                    // static linking; default is dynamic
            binaryOption("bundleId", "com.example.trailmate.shared")
            linkerOpts("-lsqlite3")           // system library the SQLDelight native driver needs
            // Make these dependencies visible from Swift (only api dependencies can be exported)
            export(libs.androidx.lifecycle.viewmodel)
            export(libs.kermit)
        }
    }
}

baseName becomes the framework and Swift module name, so import SharedLogic in Swift. isStatic = true statically links the Kotlin runtime into the app and avoids shipping a dynamic framework that has to be embedded and signed; for a single shared framework a static one is the usual choice, and it is required when several Kotlin frameworks should not each carry a runtime copy. Dynamic frameworks are the default and are the safer pick when the framework is consumed by several consumers that must share one runtime.

// iosApp/iosApp/TrailListView.swift
import SwiftUI
import SharedLogic   // the framework baseName

struct TrailListView: View {
    @State private var viewModel = TrailListViewModel()   // exported Kotlin class

    var body: some View {
        Text("Trails: \(viewModel.trailCount)")
    }
}

export()

By default a framework exposes only the module’s own public API. A dependency’s types appear in the generated header only if it is exported, and only api dependencies of the source set can be exported. When you export a dependency, its whole API goes into the framework, dead-code elimination is disabled for it, and binary size and link time grow. Export is non-transitive: exporting foo, which depends on bar, exposes only foo. transitiveExport = true changes that, and the documentation advises against it because it can increase compile time and binary size.

A multi-module layout uses export(project(…​)) to build one umbrella framework: Swift apps cannot easily load several independent Kotlin/Native frameworks, so the shared UI module exports the shared logic module into one ComposeApp-style framework.

// sharedUI/build.gradle.kts
kotlin {
    listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "SharedUI"
            isStatic = true
            export(projects.sharedLogic)    // umbrella: Swift sees both modules' API in SharedUI
        }
    }

    sourceSets {
        commonMain.dependencies {
            api(projects.sharedLogic)       // must be api to be exportable
        }
    }
}

XCFramework

An XCFramework is a bundle holding one framework per platform and architecture (device and simulator). Unlike universal ("fat") frameworks built with lipo, an XCFramework does not need unnecessary architectures stripped before App Store submission, and it is the format used to distribute a Kotlin library to Swift consumers, typically through a Swift package. Fat frameworks are legacy; use XCFrameworks.

// sharedLogic/build.gradle.kts (continued)
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework

kotlin {
    val xcf = XCFramework("SharedLogic")
    listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "SharedLogic"
            isStatic = true
            xcf.add(this)
        }
    }
}
# run from the repository root
./gradlew :sharedLogic:assembleSharedLogicReleaseXCFramework
# output: sharedLogic/build/XCFrameworks/release/SharedLogic.xcframework

The plugin registers assembleXCFramework, assemble<Name>DebugXCFramework and assemble<Name>ReleaseXCFramework tasks. Bitcode is no longer supported (since Kotlin 2.0.20), so any embedBitcode setting in an old build script should be removed. How the framework is delivered to Xcode is the subject of iOS Integration Options, and the Swift side of Swift packages is explained in Swift Packages and Modularization.

gradle.properties

gradle.properties configures the Gradle daemon, the Kotlin daemon and a few plugin behaviours. The values below are a sensible starting point for TrailMate Everywhere; none of them are secrets, and per-machine overrides belong in ~/.gradle/gradle.properties.

# gradle.properties
# Gradle daemon heap and Kotlin daemon heap: multiplatform builds are memory-hungry
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -XX:+UseParallelGC
kotlin.daemon.jvmargs=-Xmx4g

# Faster builds
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true

# Code style used by the IDE and tools
kotlin.code.style=official

# Do not fail when a target (for example iOS on Linux) cannot be built on this host
kotlin.native.ignoreDisabledTargets=true

# AndroidX is the only supported Android library family
android.useAndroidX=true
Property Effect

org.gradle.jvmargs

Heap for the Gradle daemon. Out-of-memory crashes in linkReleaseFramework* tasks usually mean this (and kotlin.daemon.jvmargs) is too small.

kotlin.daemon.jvmargs

Heap for the Kotlin compiler daemon, which is a separate process.

org.gradle.caching

Turns on the Gradle build cache; see the next section.

org.gradle.configuration-cache

Turns on the configuration cache; see the next section.

kotlin.native.ignoreDisabledTargets

Suppresses the warning for targets disabled on the current host. A multiplatform build always declares iOS targets, so a Linux CI runner would otherwise warn on every run.

kotlin.mpp.applyDefaultHierarchyTemplate

Set to false to opt out of the default hierarchy template and declare intermediate source sets by hand. Almost never the right choice in new projects.

A common mistake is to copy old flags such as kotlin.mpp.enableGranularSourceSetsMetadata or kotlin.native.useEmbeddableCompilerJar from tutorials written for Kotlin 1.x: they have no effect today, and the Kotlin Gradle plugin warns about deprecated properties. Delete the lines instead of keeping them.

Configuration cache and build cache

Two caches speed up different phases, and a multiplatform build benefits from both because it runs many tasks (one compile and one link per target and build type).

Cache What it stores Enabled by

Configuration cache

The result of the configuration phase (the task graph), reused when build scripts and inputs are unchanged

org.gradle.configuration-cache=true

Build cache

The outputs of individual tasks, keyed by their inputs, reusable across builds and (with a remote cache) machines

org.gradle.caching=true

Both are recommended by the Kotlin documentation. The plugins in this build (Kotlin, Compose, SQLDelight, AGP 9) are compatible with the configuration cache. If it fails, the cause is almost always a custom task or script that reads project at execution time; fix it by using Gradle Provider APIs rather than disabling the cache.

# run from the repository root
./gradlew :sharedLogic:allTests --configuration-cache      # second run: "Configuration cache entry reused."
./gradlew :sharedLogic:assemble --build-cache --scan        # see cache hits in the Build Scan

For Kotlin/Native specifically, the iOS targets dominate the build time. The documentation’s tuning list: keep the Gradle and Kotlin daemons enabled and sized as above, avoid building more Apple targets than needed (develop against iosSimulatorArm64 only and let release builds link the device target), prefer a static framework and modularize so that unchanged modules are reused from caches, and avoid transitiveExport. In CI, build a debug simulator framework for pull requests and the release XCFramework only on release tags.

Kotlin, Gradle, AGP and Xcode compatibility

A build works when four version lines agree: Kotlin, Gradle, the Android Gradle Plugin and Xcode. The official compatibility guide records the tested range for each Kotlin release.

Tool Kotlin 2.4.20 tested range TrailMate Everywhere uses

Gradle

7.6.3 through 9.7.0

9.8.0 (newer than the tested range)

Android Gradle Plugin

8.5.2 through 9.3.1 (8.5.2 is the minimum)

9.4.1 (newer than the tested range)

Xcode

Xcode 26.4

Xcode 26.4, pinned in CI

Compose Multiplatform

Uses the Kotlin version through org.jetbrains.kotlin.plugin.compose

1.12.1 (Android, iOS, desktop Stable; web Beta)

Android-KMP library plugin

Needs AGP 8.10 or later

com.android.kotlin.multiplatform.library 9.4.1

Two points follow. First, Gradle 9.8.0 and AGP 9.4.1 are newer than the range Kotlin 2.4.20 was tested against. Newer versions usually work, and AGP 9 requires Gradle 9.x anyway, but a failure in a newer-than-tested combination is something to report and, if blocking, to resolve by moving Kotlin forward (Kotlin 2.5.0 is planned for December 2026; 2.5.0-Beta1 is already available) rather than by downgrading AGP. Second, Xcode is the least forgiving: a Kotlin release is tested against one Xcode version, and a newer Xcode can break the Kotlin/Native link step until Kotlin catches up. A fresh macos-latest GitHub runner ships a newer Xcode (26.6 at the time of writing), so pin 26.4 explicitly. See CI/CD with GitHub Actions.

AGP 9 rejects com.android.library and com.android.application combined with the KMP plugin, and an Xcode newer than the tested one is the usual cause of an iOS link failure after an update. The Android side is covered in The Android Target and AGP 9.

Removed DSL: ios() shortcuts and target presets

The multiplatform DSL has shed two generations of APIs. Both are gone from Kotlin 2.4.20; they appear in older tutorials, books and Stack Overflow answers, so you should recognize them and know the replacement.

Legacy API Replacement Timeline

ios(), watchos(), tvos() target shortcuts

List the targets explicitly (iosArm64(), iosSimulatorArm64()); the default hierarchy template creates iosMain and iosTest automatically

Warning in 1.9.20, error in 2.1.0, removed in 2.2.0

presets, fromPreset(presets.iosArm64, "ios"), targetFromPreset

Call the target function directly (iosArm64())

Warning in 1.9.20, error in 2.0.0, removed from the public API in 2.2.0

kotlinOptions \{ \}

compilerOptions \{ \}

Replaced by the typed compiler-options DSL

The ios() shortcut created iosArm64 and iosX64 but not iosSimulatorArm64, which Apple Silicon Macs need; changing it would have broken existing projects, so it was deprecated instead. The replacement is also shorter in effect: you state the targets, and the plugin derives the intermediate source sets from that list. With iosArm64 and iosSimulatorArm64 it creates iosMain and iosTest; with iosArm64 and macosArm64 it creates appleMain and appleTest.

// sharedLogic/build.gradle.kts
// Legacy (removed in Kotlin 2.2.0) - do not write this:
//   ios()                       // iosArm64 + iosX64, no simulator on Apple Silicon
//   fromPreset(presets.iosArm64, "ios")

// Current
kotlin {
    iosArm64()
    iosSimulatorArm64()
    // iosX64() is Tier 3; add it only if you still need Intel-Mac simulators
}

Keep an eye on target deprecations too: macosX64, tvosX64 and watchosX64 were deprecated in Kotlin 2.3.20 and watchosArm32 in 2.4.20, and iosX64 is Tier 3. See How KMP Compiles for the full tier list.

Common mistakes

Mistake Fix

Applying com.android.library or kotlin-android in a KMP module

Use com.android.kotlin.multiplatform.library and configure it in kotlin \{ android \{ \} \}.

Hard-coding versions in each module

Put every version in libs.versions.toml and reference it with libs.*.

kotlinOptions \{ \}, ios() or presets copied from an old tutorial

Use compilerOptions \{ \} and explicit targets.

A platform-only library in commonMain

Move it to androidMain, iosMain or desktopMain.

Forgetting api when a module should be exported to Swift

Declare the dependency with api, then export(…​) in the framework.

transitiveExport = true as a default

Export only the types Swift really needs; it inflates the binary and slows linking.

Building on a Mac with an Xcode newer than the Kotlin release supports

Pin the supported Xcode version, locally and in CI.

Using the Experimental top-level dependencies \{ \} in a published library

Use sourceSets \{ commonMain.dependencies \{ \} \}.