Imported from ronjunevaldoz/graphyn-editor (
.agents/skills/kotlin-multiplatform-feature-scaffold/SKILL.md). Install upstream withnpx skills add ronjunevaldoz/graphyn-editor --skill kotlin-multiplatform-feature-scaffold. Copyright stays with the author (Apache-2.0).
When to Use This Skill
Use when you need to:
- Create a new Kotlin Multiplatform project from scratch, starting from Kotlin/kmp-wizard
(usually the
all-targetsbranch when you want Android + iOS + Web + Desktop + Server) - Add a new feature module group (
:model/:api/:domain/:data/:presenter/:ui) to an existing KMP project - Set up AGP 9+ build-logic convention plugins and a version catalog
- Set up AGP 9+ build-logic convention plugins backed by
gradle/libs.versions.toml - Wire Koin 4 DI (annotated or manual) across KMP modules
This is the foundational skill — most other KMP skills (network-layer, sqldelight-setup,
navigation, design-system, etc.) require the project structure this skill creates.
Trigger keywords: create KMP project, scaffold feature module, new module, set up KMP, add feature, multi-module, build-logic, convention plugin, AGP 9, Koin 4, KMP setup, Kotlin/kmp-wizard, generate from template, baseline project, add a screen, new screen, new feature, new feature module, add feature layer, scaffold module, create module, add KMP screen, set up convention plugin.
Branch recommendation: default to the all-targets branch for full-stack KMP apps.
Use all-frontends-shared only when you want Android + iOS + Web + Desktop without a
server module.
Build-logic rule: always route module configuration through convention plugins in
build-logic/ and keep versions in gradle/libs.versions.toml; do not scatter plugin
and dependency versions across module build files.
Freshness rule: AGP, Kotlin, CMP, and Koin version targets change quickly — recheck the
version table in PLAN.md and the kmp-wizard repo before scaffolding a new project.
Recommendation First
Default to kmp-wizard all-targets branch + build-logic convention plugins + gradle/libs.versions.toml.
Why:
all-targetsgives Android + iOS + Web + Desktop + Server in one baseline — easier to trim than to add targets later- convention plugins enforce consistent AGP/Kotlin configuration across every module
- a single version catalog eliminates version drift between modules
Use a narrower branch (all-frontends-shared) only when the product explicitly excludes server.
Never scaffold by hand — always start from kmp-wizard to avoid missing targets or misconfigured plugins.
Overview
This skill produces a KMP multi-feature module architecture with the following decisions baked in:
- AGP 9 minimum using the new
com.android.kotlin.multiplatform.libraryplugin (replaces the oldkotlin("multiplatform")+com.android.librarypair for library modules) - build-logic as a Gradle included build providing precompiled convention plugins
- Version catalog (
gradle/libs.versions.toml) with proper group prefixes and bundles - Feature split: every feature is 6 modules —
:model/:api/:domain/:data/:presenter/:ui - Core modules:
:core:common,:core:network,:core:database,:core:ui - Compose Multiplatform (CMP) as the default shared UI layer (CMP-first)
- Koin 4 DI — annotated (default, via Koin Compiler Plugin) or manual
Module dependency graph (per feature)
:feature:<name>:model pure KMP — data classes, sealed types, enums (no deps)
↑
:feature:<name>:api pure KMP — interfaces, nav contracts (depends on :model)
↑
:feature:<name>:domain pure KMP — use cases, business logic (depends on :api)
↑
:feature:<name>:data KMP + platform impls — Ktor, SQLDelight (depends on :api, NOT :domain)
:feature:<name>:presenter pure KMP — ViewModels, MVI contracts (depends on :domain, NO Compose)
↑
:feature:<name>:ui CMP — Compose screens + previews (depends on :presenter ONLY)
:data and :presenter are siblings — neither depends on the other.
:presenter has NO Compose dependency, so ViewModels are testable on plain JVM.
Mode Detection
Before doing anything, inspect the working directory:
- New Project mode: no
settings.gradle.ktsor nobuild-logic/directory found. Scaffold the full project by copying the Kotlin/kmp-wizard AGP 9all-targetsbaseline first, then layer the multi-feature module architecture on top. - Add Feature mode: existing KMP project detected (has
settings.gradle.ktsandbuild-logic/). Only scaffold the new feature module group.
Step 1: Gather User Input
Always ask before creating any files. Collect these values from the user:
| Input | Description | Example |
|---|---|---|
PROJECT_NAME |
Root project name (PascalCase) | MyAwesomeApp |
GROUP_ID |
Base package / Maven group ID | com.example.myapp |
FEATURE_NAME |
First feature to scaffold (snake_case) | auth |
DI_APPROACH |
annotated (default) or manual |
annotated |
In Add Feature mode, only GROUP_ID, FEATURE_NAME, and DI_APPROACH are needed.
Step 2: Version Reference
Use these exact versions. Do not substitute without explicit user confirmation.
agp = "9.0.1"
kotlin = "2.4.0"
ksp = "2.3.9"
koin = "4.2.1"
koin-annotations = "2.3.1"
ktor = "3.1.3"
sqldelight = "2.0.2"
compose-multiplatform = "1.11.1"
buildkonfig = "0.21.2"
android-compileSdk = "36"
android-minSdk = "24"
android-targetSdk = "36"
androidx-lifecycle = "2.11.0-beta01"
androidx-activity = "1.13.0"
coroutines = "1.10.2"
serialization = "1.11.0"
datetime = "0.8.0"
Note on Koin DI: Koin 4.1+ ships a native Kotlin Compiler Plugin (
org.jetbrains.kotlin.plugin.koin) that replaces the KSP-based annotation processor for KMP projects — no per-platform KSP configuration needed. Use this forannotatedmode. Formanualmode, skip the plugin entirely and write explicitmodule {}blocks.
Note on BuildKonfig:
com.codingfeline.buildkonfigis the KMP equivalent of Android'sBuildConfig. It generates aBuildKonfigobject accessible fromcommonMain,androidMain, andiosMain. Configure it in:androidApp'sbuild.gradle.ktsusing abuildkonfig {}block.
App Versioning
Three tools, one responsibility each:
| Tool | Role |
|---|---|
gradle.properties |
Single source of truth — declare VERSION_NAME and VERSION_CODE here. CI bumps this one file. |
libs.versions.toml |
Dependency/plugin versions only — never put app version here. |
BuildKonfig |
Expose APP_VERSION to commonMain so shared code can read it (User-Agent, about screen, analytics). |
gradle.properties — add alongside the Gradle performance flags:
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC
org.gradle.configuration-cache=true
org.gradle.parallel=true
kotlin.code.style=official
# App version — bump here; read everywhere else
VERSION_NAME=1.0.0
VERSION_CODE=1
androidApp/build.gradle.kts — read from properties:
android {
defaultConfig {
versionCode = (project.property("VERSION_CODE") as String).toInt()
versionName = project.property("VERSION_NAME") as String
}
}
buildkonfig {} block — expose version to commonMain:
buildkonfig {
packageName = "GROUP_ID"
defaultConfigs {
buildConfigField(STRING, "APP_NAME", "PROJECT_NAME")
buildConfigField(STRING, "APP_VERSION", project.property("VERSION_NAME") as String)
buildConfigField(STRING, "BASE_URL", "https://api.example.com")
buildConfigField(BOOLEAN, "DEBUG", "false")
}
targetConfigs {
create("debug") {
buildConfigField(BOOLEAN, "DEBUG", "true")
buildConfigField(STRING, "BASE_URL", "https://api-staging.example.com")
}
}
}
AppConfig in commonMain — the public facade:
object AppConfig {
val versionName: String get() = BuildKonfig.APP_VERSION
val baseUrl: String get() = BuildKonfig.BASE_URL
val isDebug: Boolean get() = BuildKonfig.DEBUG
}
CI version bump (no Gradle plugin needed):
# In your release script or CI step:
sed -i "s/^VERSION_NAME=.*/VERSION_NAME=$NEW_VERSION/" gradle.properties
sed -i "s/^VERSION_CODE=.*/VERSION_CODE=$NEW_CODE/" gradle.properties
git commit -am "chore: bump version to $NEW_VERSION"
iOS note:
VERSION_NAMEandVERSION_CODEflow intoCFBundleShortVersionStringandCFBundleVersionvia your Xcode project or axcconfigfile — seekotlin-multiplatform-xcframework-spmfor the full iOS release pipeline.
Library publishing note: for KMP libraries (not apps), declare
versioningradle.propertiesand read it withversion = project.property("VERSION_NAME")in the module'sbuild.gradle.kts. Do not useBuildKonfigin libraries — it is an app-only tool.
Step 3: New Project — Clone kmp-wizard (MANDATORY)
Never create build infrastructure by hand. Always start from the official
Kotlin/kmp-wizardrepository. Hand-writingbuild-logic, convention plugins, orsettings.gradle.ktsfrom scratch leads to misconfigured Gradle included builds, broken precompiled script plugin accessor generation, and missing platform targets. The wizard gives you a known-good baseline; your job is to configure and extend it.
3a. Clone the baseline
# Default: all platforms (Android + iOS + Desktop + Web + Server)
git clone --depth 1 --branch all-targets \
https://github.com/Kotlin/kmp-wizard <PROJECT_NAME>
# Frontend-only (no server module):
git clone --depth 1 --branch all-frontends-shared \
https://github.com/Kotlin/kmp-wizard <PROJECT_NAME>
cd <PROJECT_NAME>
rm -rf .git # detach from kmp-wizard history
git init # start fresh project history
Choose all-targets by default. Use all-frontends-shared only when the project
explicitly excludes a server module.
3b. Configure the clone
After cloning, make these targeted edits — do not rewrite the files:
settings.gradle.kts — update the root project name:
rootProject.name = "PROJECT_NAME"
gradle/libs.versions.toml — update to the target versions from Step 2:
agp = "9.0.1"
kotlin = "2.4.0"
compose-multiplatform = "1.11.1"
# … update all version entries to match Step 2 table
build-logic/convention/src/main/kotlin/ — rename every convention plugin file
by substituting the wizard's placeholder group ID with GROUP_ID:
# Example: if kmp-wizard uses "org.example" as placeholder
for f in build-logic/convention/src/main/kotlin/*.kt; do
mv "$f" "${f/org.example/GROUP_ID}"
done
# Then update the group ID string inside each file
find build-logic/convention/src/main/kotlin -name "*.kt" \
-exec sed -i '' 's/org\.example/GROUP_ID/g' {} +
androidApp/build.gradle.kts and any applicationId occurrences — replace
the wizard placeholder with GROUP_ID.
3c. Verify the base builds
Run this before adding any modules:
./gradlew help
BUILD SUCCESSFUL means the base is sound. Fix any version resolution errors
before proceeding. Do not add feature modules to a broken base.
Step 4: Extend build-logic with KMM Convention Plugins
kmp-wizard ships with its own convention plugins. You need to add the 6-layer KMM-specific plugins on top — do not replace the wizard's existing plugins.
4a. Add plugin dependencies to build-logic/convention/build.gradle.kts
Add any missing plugin dependencies the wizard doesn't include (e.g. SQLDelight, Roborazzi). Do not remove what the wizard already declares:
dependencies {
// Keep whatever kmp-wizard already has, then add:
compileOnly(libs.sqldelight.gradlePlugin)
compileOnly("io.github.takahirom.roborazzi:io.github.takahirom.roborazzi.gradle.plugin:${libs.versions.roborazzi.get()}")
}
Add the new plugin registrations to the existing gradlePlugin { plugins { … } } block:
gradlePlugin {
plugins {
// Keep whatever kmp-wizard registers, then add:
register("featureModel") { id = "GROUP_ID.feature.model"; implementationClass = "FeatureModelConventionPlugin" }
register("featureApi") { id = "GROUP_ID.feature.api"; implementationClass = "FeatureApiConventionPlugin" }
register("featureDomain") { id = "GROUP_ID.feature.domain"; implementationClass = "FeatureDomainConventionPlugin" }
register("featureData") { id = "GROUP_ID.feature.data"; implementationClass = "FeatureDataConventionPlugin" }
register("featurePresenter"){ id = "GROUP_ID.feature.presenter";implementationClass = "FeaturePresenterConventionPlugin" }
register("featureUi") { id = "GROUP_ID.feature.ui"; implementationClass = "FeatureUiConventionPlugin" }
register("core") { id = "GROUP_ID.core"; implementationClass = "CoreConventionPlugin" }
}
}
Class-based plugins only. Do NOT use precompiled
.gradle.ktsscript plugins for convention plugins in included builds — Gradle 9'sgeneratePrecompiledScriptPluginAccessorsdoes not generate version catalog type-safe accessors for included builds, causing everylibs.*reference to fail with "Unresolved reference". Always write convention plugins as classes implementingPlugin<Project>and access the catalog viaextensions.getByType<VersionCatalogsExtension>().named("libs").
4b. Add missing catalog entries to gradle/libs.versions.toml
Only add what the wizard doesn't already have (check before adding):
[versions]
sqldelight = "2.0.2"
roborazzi = "1.29.0"
turbine = "1.2.1"
datetime = "0.8.0"
koin = "4.2.1"
[libraries]
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-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-gradlePlugin = { module = "app.cash.sqldelight:gradle-plugin", version.ref = "sqldelight" }
roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" }
roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" }
roborazzi-junit-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-core-viewmodel = { module = "io.insert-koin:koin-core-viewmodel", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }
Step 5: Convention Plugin Templates
IMPORTANT — file naming: With Gradle precompiled script plugins, the file name IS the plugin ID. When scaffolding, rename every template file by replacing
GROUP_IDwith your actual reversed-domain group ID (dots are valid in filenames here).Example for
GROUP_ID = com.example.myapp:
GROUP_ID.feature.api.gradle.kts→com.example.myapp.feature.api.gradle.ktsGROUP_ID.android.app.gradle.kts→com.example.myapp.android.app.gradle.kts- etc.
The template folder
templates/build-logic/convention/src/main/kotlin/contains all eight files pre-named with theGROUP_IDplaceholder for this purpose.
GROUP_ID.feature.model.gradle.kts
Pure KMP — no framework deps. Data classes, sealed types, enums. Zero dependencies.
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.jetbrains.kotlin.multiplatform")
id("com.android.kotlin.multiplatform.library")
}
kotlin {
jvm()
iosArm64()
iosSimulatorArm64()
androidLibrary {
compileSdk = 36
minSdk = 24
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
}
sourceSets {
commonMain.dependencies {
// intentionally empty — :model has no external deps
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
GROUP_ID.feature.api.gradle.kts
Pure KMP — no Compose, no Koin. Exposes interfaces, navigation contracts. Depends on :model.
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.jetbrains.kotlin.multiplatform")
id("com.android.kotlin.multiplatform.library")
}
kotlin {
iosArm64()
iosSimulatorArm64()
androidLibrary {
compileSdk = 36
minSdk = 24
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
GROUP_ID.feature.domain.gradle.kts
Pure KMP — use cases and business logic. No Compose, no data layer deps.
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.jetbrains.kotlin.multiplatform")
id("com.android.kotlin.multiplatform.library")
}
kotlin {
iosArm64()
iosSimulatorArm64()
androidLibrary {
compileSdk = 36
minSdk = 24
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.koin.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
}
}
GROUP_ID.feature.data.gradle.kts
KMP + platform implementations — Ktor for networking, SQLDelight for persistence.
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.jetbrains.kotlin.multiplatform")
id("com.android.kotlin.multiplatform.library")
id("app.cash.sqldelight")
}
kotlin {
iosArm64()
iosSimulatorArm64()
androidLibrary {
compileSdk = 36
minSdk = 24
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.koin.core)
implementation(libs.bundles.ktor.common)
}
androidMain.dependencies {
implementation(libs.ktor.client.android)
implementation(libs.sqldelight.android.driver)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
implementation(libs.sqldelight.native.driver)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.mock)
}
}
}
GROUP_ID.feature.presenter.gradle.kts
Pure KMP — ViewModels and MVI contracts. No Compose dependency. Testable on plain JVM.
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.jetbrains.kotlin.multiplatform")
id("com.android.kotlin.multiplatform.library")
}
kotlin {
jvm()
iosArm64()
iosSimulatorArm64()
androidLibrary {
compileSdk = 36
minSdk = 24
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.androidx.lifecycle.viewmodel) // no Compose flavour
implementation(libs.koin.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
}
}
}
GROUP_ID.feature.ui.gradle.kts
CMP — Compose Multiplatform screens only. Depends on :presenter, not on :domain or :data.
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.jetbrains.kotlin.multiplatform")
id("com.android.kotlin.multiplatform.library")
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
}
kotlin {
iosArm64()
iosSimulatorArm64()
androidLibrary {
compileSdk = 36
minSdk = 24
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
androidResources {
enable = true
}
}
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(libs.koin.compose)
}
androidMain.dependencies {
implementation(compose.uiTooling)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
GROUP_ID.core.gradle.kts
Base for all :core:* modules. Apply additional plugins per-module as needed.
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.jetbrains.kotlin.multiplatform")
id("com.android.kotlin.multiplatform.library")
}
kotlin {
iosArm64()
iosSimulatorArm64()
androidLibrary {
compileSdk = 36
minSdk = 24
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.koin.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
GROUP_ID.android.app.gradle.kts
Android application entry point.
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.koin")
}
android {
compileSdk = 36
defaultConfig {
minSdk = 24
targetSdk = 36
versionCode = (project.property("VERSION_CODE") as String).toInt()
versionName = project.property("VERSION_NAME") as String
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
}
dependencies {
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.viewmodelCompose)
}
Step 6: Feature Module build.gradle.kts Templates
For each new feature FEATURE_NAME with group GROUP_ID, create these six files.
Replace FEATURE_NAME and GROUP_ID with actual values.
:feature:FEATURE_NAME:model/build.gradle.kts
plugins {
id("GROUP_ID.feature.model")
}
kotlin {
androidLibrary {
namespace = "GROUP_ID.feature.FEATURE_NAME.model"
}
}
:feature:FEATURE_NAME:api/build.gradle.kts
plugins {
id("GROUP_ID.feature.api")
}
kotlin {
androidLibrary {
namespace = "GROUP_ID.feature.FEATURE_NAME.api"
}
sourceSets {
commonMain.dependencies {
api(projects.feature.FEATURE_NAME.model)
}
}
}
:feature:FEATURE_NAME:domain/build.gradle.kts
plugins {
id("GROUP_ID.feature.domain")
}
kotlin {
androidLibrary {
namespace = "GROUP_ID.feature.FEATURE_NAME.domain"
}
sourceSets {
commonMain.dependencies {
implementation(projects.feature.FEATURE_NAME.api)
}
}
}
:feature:FEATURE_NAME:data/build.gradle.kts
plugins {
id("GROUP_ID.feature.data")
}
kotlin {
androidLibrary {
namespace = "GROUP_ID.feature.FEATURE_NAME.data"
}
sourceSets {
commonMain.dependencies {
implementation(projects.feature.FEATURE_NAME.api)
implementation(projects.core.network)
implementation(projects.core.database)
}
}
}
:feature:FEATURE_NAME:presenter/build.gradle.kts
plugins {
id("GROUP_ID.feature.presenter")
}
kotlin {
androidLibrary {
namespace = "GROUP_ID.feature.FEATURE_NAME.presenter"
}
sourceSets {
commonMain.dependencies {
implementation(projects.feature.FEATURE_NAME.domain)
}
}
}
:feature:FEATURE_NAME:ui/build.gradle.kts
plugins {
id("GROUP_ID.feature.ui")
}
kotlin {
androidLibrary {
namespace = "GROUP_ID.feature.FEATURE_NAME.ui"
}
sourceSets {
commonMain.dependencies {
implementation(projects.feature.FEATURE_NAME.presenter)
implementation(projects.core.ui)
}
}
}
Step 7: Koin DI Patterns
Annotated mode (default — Koin Compiler Plugin)
Apply id("org.jetbrains.kotlin.plugin.koin") in the module's convention plugin or
directly in the build.gradle.kts. Then use annotations:
// In :feature:auth:domain
@Single
class GetUserUseCase(private val repo: UserRepository) {
operator fun invoke(id: String): Flow<User> = repo.getUser(id)
}
// In :feature:auth:presenter (no Compose dep — testable on JVM)
@KoinViewModel
class AuthViewModel(private val getUser: GetUserUseCase) : ViewModel() { ... }
Auto-generated modules are collected in AppModule. Declare in :androidApp:
startKoin {
androidContext(this@App)
modules(AppModule.module) // generated by Koin Compiler Plugin
}
Manual mode
Write explicit module {} blocks per feature. Convention: one <FeatureName>Module.kt
in each :domain and :data module.
// :feature:auth:domain/src/commonMain/kotlin/.../AuthDomainModule.kt
val authDomainModule = module {
factory { GetUserUseCase(get()) }
}
// :feature:auth:presenter/src/commonMain/kotlin/.../AuthPresenterModule.kt
val authPresenterModule = module {
viewModel { AuthViewModel(get()) }
}
Declare all modules in :androidApp:
startKoin {
androidContext(this@App)
modules(authDomainModule, authPresenterModule, /* ... */)
}
Step 8: Add Feature Mode
When adding a feature to an existing project:
- Create the six module directories:
feature/<FEATURE_NAME>/model/ feature/<FEATURE_NAME>/api/ feature/<FEATURE_NAME>/domain/ feature/<FEATURE_NAME>/data/ feature/<FEATURE_NAME>/presenter/ feature/<FEATURE_NAME>/ui/ - Write
build.gradle.ktsin each (see Step 6 templates above). - Add to
settings.gradle.kts:include(":feature:FEATURE_NAME:model") include(":feature:FEATURE_NAME:api") include(":feature:FEATURE_NAME:domain") include(":feature:FEATURE_NAME:data") include(":feature:FEATURE_NAME:presenter") include(":feature:FEATURE_NAME:ui") - Wire into
:androidAppdependencies:implementation(projects.feature.FEATURE_NAME.ui) - Add a preview stub beside each
*Content.ktin:feature:FEATURE_NAME:uiso preview coverage is part of the scaffold, not an optional follow-up.
Step 9: Source File Stubs
After creating build files, generate stub source files so each module compiles:
:feature:FEATURE_NAME:model
src/commonMain/kotlin/GROUP_ID/feature/FEATURE_NAME/model/
FEATURE_NAMEModel.kt ← data class(es), sealed types, enums
:feature:FEATURE_NAME:api
src/commonMain/kotlin/GROUP_ID/feature/FEATURE_NAME/api/
FEATURE_NAMERepository.kt ← interface (uses types from :model)
FEATURE_NAMENavigation.kt ← nav route objects/sealed class
:feature:FEATURE_NAME:domain
src/commonMain/kotlin/GROUP_ID/feature/FEATURE_NAME/domain/
Get<FEATURE_NAME>UseCase.kt
di/FEATURE_NAME_DomainModule.kt ← only in manual mode
:feature:FEATURE_NAME:data
src/commonMain/kotlin/GROUP_ID/feature/FEATURE_NAME/data/
FEATURE_NAMERepositoryImpl.kt
remote/FEATURE_NAMERemoteDataSource.kt
local/FEATURE_NAMELocalDataSource.kt
di/FEATURE_NAME_DataModule.kt ← only in manual mode
:feature:FEATURE_NAME:presenter
src/commonMain/kotlin/GROUP_ID/feature/FEATURE_NAME/presenter/
FEATURE_NAMEViewModel.kt ← ViewModel, no Compose import
FEATURE_NAMEUiState.kt ← MVI state sealed class
FEATURE_NAMEUiIntent.kt ← MVI intent sealed class
di/FEATURE_NAME_PresenterModule.kt ← only in manual mode
:feature:FEATURE_NAME:ui
src/commonMain/kotlin/GROUP_ID/feature/FEATURE_NAME/ui/
FEATURE_NAMEScreen.kt ← wires ViewModel from :presenter via koinViewModel()
FEATURE_NAMEContent.kt ← stateless @Composable, accepts state parameter
previews/
FEATURE_NAMEContentPreview.kt ← required preview stub for the Content composable
Step 10: Test Infrastructure
Convention plugin: GROUP_ID.feature.test.gradle.kts
A lightweight plugin that equips any module's test source sets with shared test tooling. Apply it to modules that need Turbine, coroutines-test, or shared fakes.
// In any module's build.gradle.kts test configuration
kotlin {
sourceSets {
commonTest.dependencies {
implementation(projects.core.testing) // shared fakes + builders
}
}
}
:core:testing module
Add to settings.gradle.kts:
include(":core:testing")
The module exposes (via api()):
kotlin.test— assertionskotlinx.coroutines.test—runTest,TestCoroutineSchedulerTurbine 1.2.1— Flow testing
Bundled Script
scripts/validate_module_graph.py— checks a target project for the expected:model/:api/:domain/:data/:presenter/:uifeature module files, theandroidAppfeature UI link, and the required preview stub for each*Content.ktin:feature:*:ui.
Turbine usage pattern
// commonTest — testing a ViewModel or use case that emits a Flow
@Test
fun `state emits Loading then Success`() = runTest {
val viewModel = AuthViewModel(FakeGetUserUseCase())
viewModel.uiState.test {
assertEquals(AuthUiState.Loading, awaitItem())
assertEquals(AuthUiState.Success(fakeUser), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
Shared fakes pattern in :core:testing
src/commonMain/kotlin/GROUP_ID/core/testing/
fakes/
FakeTokenStorage.kt
FakeNetworkClient.kt
builders/
UserBuilder.kt ← test data builders with defaults
rules/
MainCoroutineRule.kt ← TestCoroutineDispatcher setup
Example fake:
class FakeTokenStorage : TokenStorage {
var accessToken: String? = "test-access-token"
var refreshToken: String? = "test-refresh-token"
override suspend fun getAccessToken() = accessToken
override suspend fun getRefreshToken() = refreshToken
override suspend fun saveTokens(access: String, refresh: String) {
accessToken = access; refreshToken = refresh
}
override suspend fun clearTokens() { accessToken = null; refreshToken = null }
}
Step 11: Verification
After scaffolding, verify in order:
./gradlew help— Gradle resolves the build without errors./gradlew :feature:FEATURE_NAME:api:compileKotlinMetadata— KMP common compiles./gradlew :androidApp:assembleDebug --dry-run— Android wiring is correct- Confirm all
include()entries insettings.gradle.ktsmatch actual directories - Confirm no module references another module that it should not (enforce the layer rules:
:uidepends only on:presenter;:presenterhas NO Compose dep;:domainmust not depend on:data;:datamust not depend on:domainor:presenter) - Confirm every
*Content.ktin:feature:FEATURE_NAME:uihas a matching preview stub (*ContentPreview.ktorpreviews/*ContentPreview.kt)
Guidelines
- Never create a
buildSrc/directory — usebuild-logicinstead - Never use
id("kotlin-android")— useid("org.jetbrains.kotlin.android")(AGP 9 requirement) - Never add
android.builtInKotlinorandroid.newDsltogradle.properties— these are AGP 9 defaults - Always use
androidLibrary {}insidekotlin {}for library modules, not a standaloneandroid {}block - Always use TYPESAFE_PROJECT_ACCESSORS (
projects.feature.auth.api) — never string-based:feature:auth:api - Keep
:apimodules minimal — no DI framework dependencies, no platform deps - Namespace format:
GROUP_ID.module.path(e.g.com.example.app.feature.auth.api)
Related Skills
kotlin-multiplatform-dependency-injection— wire Koin after the module structure is in placekotlin-multiplatform-navigation— add type-safe navigation after scaffold is completekotlin-multiplatform-mvi— screen architecture layer built on top of this scaffoldkotlin-multiplatform-flavor-environment— add dev/staging/prod environments after scaffoldingkotlin-multiplatform-ci-github-actions— CI workflow consumes the module structure this skill creates
Common Anti-Patterns
- scattering plugin versions across module
build.gradle.ktsfiles instead oflibs.versions.toml— causes version drift - skipping
build-logicconvention plugins for "simple" modules — they accumulate inconsistency over time - adding
implementationdependencies in:apimodules —:apimust stay dependency-free (only:model) - adding Compose deps to
:presenter— breaks JVM testability; Compose belongs only in:ui - having
:uidepend on:domainor:datadirectly — all state must flow through:presenter - shipping a
:feature:*:uimodule with*Content.ktbut no preview stub — preview coverage must be scaffolded, not added later - putting domain types (data classes, sealed types) in
:apiinstead of:model—:apishould be interfaces only - using string project references (
:feature:auth:api) instead of typesafe accessors — breaks refactoring - scaffolding by hand instead of cloning kmp-wizard — always use
git clone Kotlin/kmp-wizardas the base; writing build-logic, convention plugins, or settings.gradle.kts from scratch causes broken Gradle included builds, missing platform targets, and cascading precompiled script plugin failures that are very hard to debug - using precompiled
.gradle.ktsscript plugins for convention plugins in included builds — Gradle 9 does not generate version catalog type-safe accessors for included builds; always use class-basedPlugin<Project>instead
If a module is failing to compile on one target, check whether the convention plugin was applied and the source sets declared correctly.
Output Style
When asked to scaffold a project or add a feature module, respond in this order:
- clarify the target (new project vs new feature module in existing project)
- version reference (confirm current AGP / Kotlin / CMP targets from PLAN.md)
- directory structure
- key file contents (build-logic convention plugin, module build file, settings)
- wire-up step (Koin module registration, nav graph entry)
Ask for GROUP_ID and feature name before generating files. Map all paths to the actual values.
Changelog
| Date | Change |
|---|---|
| 2026-06-21 | Improved — App versioning pattern defined: VERSION_NAME/VERSION_CODE in gradle.properties as the single source of truth; androidApp convention plugin reads from properties; BuildKonfig exposes APP_VERSION to commonMain; CI bump pattern documented. |
| 2026-06-21 | Breaking — Step 3 rewritten: git clone Kotlin/kmp-wizard is now mandatory. Hand-scaffolding build-logic, convention plugins, or settings.gradle.kts from scratch is no longer supported. |
| 2026-06-21 | Breaking — Step 4 rewritten: convention plugins must be class-based Plugin<Project>. Precompiled .gradle.kts script plugins in included builds do not generate version catalog accessors in Gradle 9. |
| 2026-06-18 | 6-layer module structure enforced; jvm() target added to all convention plugin templates; Step 9 source stubs expanded. |