name: modularization description: Modularize Android/KMP projects — feature-based module graph, build-logic convention plugins, module boundary rules, build time optimization, and Gradle multi-module setup for KMP targets argument-hint: "" user-invocable: true
:core:design-system shared across :feature:study and :feature:words:app
├── :feature:auth
├── :feature:study
├── :feature:words
├── :feature:profile
└── :feature:import
↓ (all features depend on)
:domain
:core:common
:core:network
:core:database
:core:design-system
:core:testing (testImplementation only)
:platforms (expect/actual for platform-specific APIs)
:resources (shared strings, assets, MR)
:feature:* → :domain, :core:*, :resources ✅:feature:* → another :feature:* ✗:domain → nothing (pure Kotlin) ✅:core:design-system → :domain, :core:network, :core:database ✗:app → all modules (wires DI, navigation) ✅The pluginManagement block must come first so build-logic convention plugins resolve before any include().
// settings.gradle.kts (root)
pluginManagement {
includeBuild("build-logic") // composite build — makes kmp.* plugins available
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "MyApp"
// App
include(":app")
// Feature modules
include(":feature:auth")
include(":feature:study")
include(":feature:words")
include(":feature:profile")
include(":feature:import")
// Domain
include(":domain")
// Core
include(":core:common")
include(":core:network")
include(":core:database")
include(":core:design-system")
include(":core:testing")
// Platform
include(":platforms")
include(":resources")
Convention plugin IDs must appear in the version catalog so modules can reference them via alias(libs.plugins.*).
[plugins]
# Convention plugins (version = "unspecified" because they come from the composite build)
kmp-library = { id = "kmp.library", version = "unspecified" }
kmp-feature = { id = "kmp.feature", version = "unspecified" }
kmp-compose = { id = "kmp.compose", version = "unspecified" }
android-library = { id = "android.library", version = "unspecified" }
# External plugins
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "agp" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
dependency-guard = { id = "com.dropbox.dependency-guard", version.ref = "dependency-guard" }
Eliminates boilerplate from every module's build.gradle.kts.
build-logic/
├── settings.gradle.kts
└── convention/
├── build.gradle.kts
└── src/main/kotlin/
├── KmpLibraryConventionPlugin.kt
├── KmpFeatureConventionPlugin.kt
├── AndroidLibraryConventionPlugin.kt
└── ComposeConventionPlugin.kt
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
versionCatalogs {
create("libs") { from(files("../gradle/libs.versions.toml")) }
}
}
rootProject.name = "build-logic"
include(":convention")
plugins {
`kotlin-dsl`
}
dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.compose.gradlePlugin)
}
gradlePlugin {
plugins {
register("kmpLibrary") {
id = "kmp.library"
implementationClass = "KmpLibraryConventionPlugin"
}
register("kmpFeature") {
id = "kmp.feature"
implementationClass = "KmpFeatureConventionPlugin"
}
register("androidLibrary") {
id = "android.library"
implementationClass = "AndroidLibraryConventionPlugin"
}
register("kmpCompose") {
id = "kmp.compose"
implementationClass = "ComposeConventionPlugin"
}
}
}
Inside Plugin<Project>, the kotlin {} / android {} DSL shortcuts are not in scope.
Use extensions.configure<> to access each extension safely.
applyDefaultHierarchyTemplate() is required so that the iosMain intermediary source set
exists when modules reference it (e.g. iosMain.dependencies { }).
// build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt
import com.android.build.gradle.LibraryExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
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> {
applyDefaultHierarchyTemplate() // creates iosMain, nativeMain, etc.
androidTarget()
iosArm64()
iosSimulatorArm64()
// Single toolchain call replaces both compileOptions and kotlinOptions.jvmTarget
jvmToolchain(17)
sourceSets {
commonMain.dependencies {
implementation(libs.findLibrary("koin-core").get())
implementation(libs.findLibrary("coroutines-core").get())
}
androidMain.dependencies {
implementation(libs.findLibrary("koin-android").get())
}
}
}
extensions.configure<LibraryExtension> {
compileSdk = 36
// namespace must be set per-module; derive it from the project path
namespace = "com.example${target.path.replace(':', '.').replace('-', '_')}"
defaultConfig { minSdk = 24 }
}
}
}
}
// build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
class KmpFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("kmp.library") // applies KmpLibraryConventionPlugin
pluginManager.apply("kmp.compose") // applies ComposeConventionPlugin
// kmp.library has already applied the KMP plugin; configure the extension directly
extensions.configure<KotlinMultiplatformExtension> {
sourceSets {
commonMain.dependencies {
implementation(project(":domain"))
implementation(project(":core:common"))
implementation(project(":core:design-system"))
implementation(project(":resources"))
}
commonTest.dependencies {
implementation(project(":core:testing"))
}
}
}
}
}
}
:app wires navigation, DI, and the Android entry point. It is the only module allowed to depend on all other modules.
// app/build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kmp.compose)
}
kotlin {
androidTarget()
sourceSets {
androidMain.dependencies {
implementation(project(":feature:auth"))
implementation(project(":feature:study"))
implementation(project(":feature:words"))
implementation(project(":feature:profile"))
implementation(project(":feature:import"))
implementation(project(":domain"))
implementation(project(":core:common"))
implementation(project(":core:network"))
implementation(project(":core:database"))
implementation(project(":core:design-system"))
implementation(project(":resources"))
implementation(project(":platforms"))
}
}
}
android {
namespace = "com.example.app"
compileSdk = 36
defaultConfig {
applicationId = "com.example.app"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
}
}
// feature/study/build.gradle.kts
plugins {
alias(libs.plugins.kmp.feature) // applies KmpFeatureConventionPlugin — everything configured
}
kotlin {
sourceSets {
commonMain.dependencies {
// Only module-specific deps beyond the convention defaults
implementation(libs.some.extra.library)
}
}
}
// domain/build.gradle.kts
plugins {
alias(libs.plugins.kotlin.multiplatform)
// NO android plugin — domain is pure Kotlin
}
kotlin {
jvm() // for unit tests on JVM
iosArm64()
iosSimulatorArm64()
jvmToolchain(17)
sourceSets {
commonMain.dependencies {
implementation(libs.coroutines.core)
implementation(libs.kotlinx.datetime)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.coroutines.test)
}
}
}
// core/common/build.gradle.kts
plugins { alias(libs.plugins.kmp.library) }
kotlin {
sourceSets {
commonMain.dependencies {
api(libs.arrow.core) // Try<T> — api so consumers get it
api(libs.coroutines.core)
}
}
}
iosMain is available because KmpLibraryConventionPlugin calls applyDefaultHierarchyTemplate().
// core/network/build.gradle.kts
plugins { alias(libs.plugins.kmp.library) }
kotlin {
sourceSets {
commonMain.dependencies {
implementation(project(":core:common"))
api(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.json)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies { // exists because of applyDefaultHierarchyTemplate()
implementation(libs.ktor.client.darwin)
}
}
}
// core/database/build.gradle.kts
plugins {
alias(libs.plugins.kmp.library)
alias(libs.plugins.sqldelight)
}
sqldelight {
databases {
create("AppDatabase") {
packageName.set("com.example.db")
verifyMigrations.set(true)
}
}
}
Depends only on Compose Multiplatform. No :domain, no network, no database.
// core/design-system/build.gradle.kts
plugins {
alias(libs.plugins.kmp.library)
alias(libs.plugins.kmp.compose)
}
kotlin {
sourceSets {
commonMain.dependencies {
// Compose multiplatform UI primitives only
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
}
}
}
// core/testing/build.gradle.kts
plugins { alias(libs.plugins.kmp.library) }
kotlin {
sourceSets {
commonMain.dependencies { // test helpers exposed as main (only used in test source sets)
implementation(project(":domain"))
implementation(libs.kotlin.test)
implementation(libs.coroutines.test)
implementation(libs.turbine)
}
}
}
Houses expect/actual declarations for APIs that differ per platform (e.g., file system, UUID, clock).
platforms/
└── src/
├── commonMain/kotlin/platforms/
│ ├── FileSystem.kt # expect fun readFile(path: String): String
│ └── Uuid.kt # expect fun randomUuid(): String
├── androidMain/kotlin/platforms/
│ ├── FileSystem.android.kt
│ └── Uuid.android.kt
└── iosMain/kotlin/platforms/
├── FileSystem.ios.kt
└── Uuid.ios.kt
// platforms/build.gradle.kts
plugins { alias(libs.plugins.kmp.library) }
// No extra dependencies — expect/actual only
Centralises shared string resources using Moko Resources (or another MR library).
// resources/build.gradle.kts
plugins {
alias(libs.plugins.kmp.library)
alias(libs.plugins.moko.resources) // or multiplatform-resources
}
multiplatformResources {
resourcesPackage.set("com.example.resources")
}
Each :feature:X follows identical internal layout:
feature/study/
└── src/
└── commonMain/kotlin/feature/study/
├── di/
│ └── StudyModule.kt # Koin module
├── domain/ # Feature-specific use cases (if not in :domain)
│ └── GetDueWordsUseCase.kt
├── presentation/
│ ├── StudyScreen.kt
│ ├── StudyViewModel.kt
│ └── StudyState.kt
└── navigation/
└── StudyNavigation.kt # Route definitions + composable extensions
// feature/study/src/commonMain/kotlin/feature/study/navigation/StudyNavigation.kt
@Serializable object StudyRoute
fun NavGraphBuilder.studyGraph(onNavigateToWord: (Int) -> Unit) {
composable<StudyRoute> {
StudyScreen(onNavigateToWord = onNavigateToWord)
}
}
Wire in :app:
// app/src/commonMain/kotlin/navigation/AppNavGraph.kt
NavHost(navController, startDestination = StudyRoute) {
studyGraph(onNavigateToWord = { id -> navController.navigate(WordDetailRoute(id)) })
wordsGraph(onNavigateToStudy = { navController.navigate(StudyRoute) })
authGraph(onAuthSuccess = { navController.navigate(StudyRoute) { popUpTo(0) } })
}
// feature/study/src/commonMain/kotlin/feature/study/di/StudyModule.kt
val studyModule = module {
viewModel { StudyViewModel(get(), get()) }
factory { GetDueWordsUseCase(get()) }
}
Registered in :app:
// app/src/commonMain/kotlin/di/AppModule.kt
startKoin {
modules(
domainModule,
networkModule,
databaseModule,
studyModule,
wordsModule,
authModule,
)
}
Check both release and debug classpaths to catch all illegal dependencies.
// build.gradle.kts (root)
plugins {
alias(libs.plugins.dependency.guard)
}
dependencyGuard {
configuration("releaseRuntimeClasspath")
configuration("debugRuntimeClasspath")
}
Run ./gradlew dependencyGuard to generate baselines, then ./gradlew dependencyGuardBaseline to update them after intentional changes.
# detekt.yml
complexity:
ForbiddenImport:
active: true
imports:
- value: 'androidx.room.*'
reason: 'Use domain repository interfaces, not Room directly in feature modules'
- value: 'io.ktor.*'
reason: 'Network access only via data layer interfaces'
# Check no illegal cross-feature dependency
./gradlew :feature:study:dependencies | grep ':feature:'
# Should only see :feature:study itself, never another :feature:*
# Check domain has no Android imports
./gradlew :domain:dependencies | grep 'androidx'
# Should return nothing
# gradle.properties
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configureondemand=true
kotlin.incremental.multiplatform=true
# Each module produces its own build cache entry.
# A change in :feature:study does NOT recompile :feature:words.
| Mistake | Fix |
|---|---|
pluginManagement { includeBuild("build-logic") } missing |
Must be the first block in root settings.gradle.kts before any include() |
kotlin { } / android { } used directly in Plugin<Project> |
Use extensions.configure<KotlinMultiplatformExtension> / extensions.configure<LibraryExtension> |
iosMain.dependencies { } fails to resolve |
Call applyDefaultHierarchyTemplate() in the convention plugin to create the intermediary source set |
Missing namespace in LibraryExtension |
AGP 7.3+ requires namespace; set it per module, not in the convention plugin |
compileOptions { sourceCompatibility = JavaVersion.VERSION_17 } |
Replace with jvmToolchain(17) — single call covers both Java and Kotlin toolchains |
| Version catalog missing plugin aliases | Add kmp-library, kmp-feature, kmp-compose entries with version = "unspecified" |
:core:design-system importing :domain |
Design system must depend only on Compose — no business types leak into UI primitives |
:core:testing on production classpath |
Only ever use via commonTest.dependencies { } or testImplementation |
Phase 1 — Extract core (no feature changes)
:core:common with Try<T>, BaseViewModel, shared utilities:core:network with Ktor client:core:database with SQLDelight schema:app to depend on thesePhase 2 — Extract domain
:domain:app temporarilyPhase 3 — Extract features one at a time
:appPhase 4 — Clean up :app
:app should contain only: MainActivity, KoinApplication, AppNavGraph, manifestpluginManagement { includeBuild("build-logic") } is the first block in root settings.gradle.ktsextensions.configure<KotlinMultiplatformExtension> — not bare kotlin { }applyDefaultHierarchyTemplate() called in KmpLibraryConventionPluginnamespace set for every Android module (AGP 8.x requirement)jvmToolchain(17) used — not compileOptionskmp-library, kmp-feature, kmp-compose plugin aliasesbuild.gradle.kts:domain has zero Android/framework imports:feature:X depends on :domain and :core:*, never on another :feature:*:core:design-system depends on nothing except Compose multiplatform:platforms contains only expect/actual declarations:app:app:core:testing only appears as commonTest.dependencies { } — never production classpathdependencyGuard checks both releaseRuntimeClasspath and debugRuntimeClasspathorg.gradle.parallel=true and org.gradle.caching=true in gradle.properties:feature:study does not invalidate :feature:words cache