- Read settings.gradle.kts to find the 14 real modules and spot which folders aren't part of the build
- Explain what each module owns, why :shared-app holds the feature list, and which way dependencies point between modules
- Tell commonMain, androidMain and iosMain apart, and recognize modules declared through the kta.kmp.library convention plugin
Read The Module Graph
Read a Kotlin Multiplatform project as a system: modules, dependency direction and source sets.
Lesson video is on the way
While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.
Lesson Goal
You will read the map of a production Kotlin Multiplatform (KMP) app: which modules exist, what each one owns, which way dependencies point, and which code runs on which platform.
After this lesson, when you look at a new screen, a use case, a repository implementation or a shared UI component, you will know which module should own it. When you pick up a different KMP project, you will know which files to read first to rebuild that map.
Why Read The Module Graph First
Before I dig into a project, I always look at how it organizes its modules. Some projects use a single module, some split modules by business domain, some split by layer (feature, domain, data). Each approach has its own strengths and weaknesses, and the right choice depends on the project and the team.
A KMP project adds another axis: which code is shared by every platform, and which is only for Android or iOS. The folders on disk can't answer these two questions. The build files can.
Part 1: A Folder Is Not A Module
Open settings.gradle.kts. The include(...) list has 14 modules:
:app
:dre-core
:dre-viewmodel
:kernel
:core
:data
:domain
:secrets
:theme
:ui-components
:feature:game
:feature:menu
:feature:paywall
:shared-app
Looking at the root folder tree, you also see build-logic/, iosApp/, ai/, scripts/, graphics/, docs/... Of these, only build-logic/ takes part in the Gradle build, through the includeBuild("build-logic") line. iosApp/ is an Xcode project: it uses Gradle's output but isn't a Gradle module.
| Kind of folder | What it means |
|---|---|
Listed in include(...) | A real Gradle module |
includeBuild("build-logic") | A composite build that provides the convention plugins |
iosApp/ | The iOS app shell; Xcode builds it and links it with the Kotlin framework |
Not in settings.gradle.kts | Not part of the build. Don't read these while tracing an app bug |
The rule: settings.gradle.kts is the source of truth. For any Gradle project (plain Android or KMP), read this file first.
Part 2: What Each Module Owns
| Module | What it owns | File to open first |
|---|---|---|
:app | The Android entry point: Application, Activity, WorkManager, notification channels | App.kt, LauncherActivity.kt |
iosApp/ | The iOS entry point in Swift, very thin | iOSApp.swift, ContentView.swift |
:shared-app | The app code shared by both platforms: startup, the feature list, root navigation, Splash. Builds the Shared framework for iOS | StartSharedApp.kt, AppFeatures.kt, AppNavHost.kt |
:core | Contracts shared between modules: route data, feature registries, base types, Compose resources | FeatureRegistry.kt, Routes.kt |
:kernel | The lowest technical foundation, for example dispatchers | QzDispatchers.kt |
:dre-core, :dre-viewmodel | The in-house state management library: Reducer, SideEffectHandler, DreStoreViewModel | Reducer.kt, DreStoreViewModel.kt |
:domain | Use cases, repository and service contracts, business models | domain/.../usecase/, domain/.../service/ |
:data | Implementations of the contracts: Supabase, Ktor, DataStore, RevenueCat... | DataModule.kt |
:secrets | The boundary for accessing secrets | SecretManager.kt |
:theme | Design system tokens and the app theme | QzdsTheme.kt, QzdsColorTokens.kt |
:ui-components | Compose components shared across screens | ui-components/.../ui/component/ |
:feature:* | User flows: screens, feature ViewModels, the feature's navigation entry | feature/game, feature/menu |
:feature:paywall is part of the build, but the premium gate is off by default, so the app never navigates to the paywall.
Part 3: Dependency Direction
Open app/build.gradle.kts and shared-app/build.gradle.kts, then compare their dependencies:
:app -> :shared-app, :domain, :secrets
(debug: :theme, :ui-components, :feature:paywall; test: :core)
:shared-app -> :feature:game, :feature:menu, :feature:paywall, :core, :data, :domain, ...
:feature:* -> :core, :domain, :kernel, :theme, :ui-components
(game and menu also :dre-core, :dre-viewmodel)
:data -> :domain, :core, :kernel, :secrets
:domain -> :dre-core
Two points worth remembering:
:appdoesn't depend on any feature module (exceptdebugImplementation(project(":feature:paywall"))for a debug screen).:appdoesn't declare:dataeither::datareaches the APK through:shared-app.:shared-appis the one that knows the feature list. The reason: iOS has no:app. If the feature list lived in:app, iOS would have no features.- Features don't depend on
:dataand don't depend on each other. A feature only knows the contracts in:domainand:core. The menu can open the game screen without importing a single game class, thanks to the navigation contract in:coreand the provider list in:shared-app.
The arrows only go one way, from the app shell down to features, down to the domain. When Gradle refuses to let you import a class, that's usually a boundary doing its job.
Part 4: One Module, Several Source Sets
Open feature/menu/src/. You see commonMain, commonTest and androidHostTest. Open data/src/ and you also see androidMain and iosMain.
| Source set | Where it runs | Example in the repo |
|---|---|---|
commonMain | Android and iOS | Every screen, ViewModel and use case, plus DataModule.kt |
androidMain | Android only | DataModule.android.kt: Firebase, SoundPoolAudioManager |
iosMain | iOS only | DataModule.ios.kt: AvAudioManager, NoOpAnalyticsManager |
commonTest | The JVM and the iOS Simulator | Tests of shared logic |
androidHostTest | The JVM on your machine | Tests that need JVM-only libraries, such as MockK |
Most code lives in commonMain. Code only drops into androidMain or iosMain when it has to call a platform's SDK. Even then, the contract stays in commonMain and each platform provides an implementation through Koin.
This repo's feature modules have no androidMain or iosMain. The whole UI is written once in Compose Multiplatform.
Part 5: Module Names Can Lie About Scope
Open feature/menu/src/commonMain/kotlin/com/creative/kta/feature/. You won't only find menu/:
feature/menu/.../feature/
├── menu/ Menu screen, MenuViewModel, dialog layer
├── settings/ Settings screen, time picker, SettingViewModel
├── leaderboard/ Leaderboard popup, LeaderboardViewModel
└── onboard/ Onboarding overlay, MenuOnboardingViewModel
One Gradle module holds four sub-features. All four flows start from the menu, share dependencies and always ship together. Splitting them into four modules would add build time and complexity and give nothing back.
A module can hold several features, as long as they are tightly bound to each other.
Part 6: The Boundary Between :core, :domain And :data
| Need | Module |
|---|---|
| Route data classes, feature registries, shared base types | :core |
| Dispatchers and the lowest technical primitives | :kernel |
| A business operation or an interface a feature needs | :domain |
| Code that talks to Supabase, Ktor, DataStore or a platform SDK | :data |
Example: AuthService is a contract and lives in :domain. AuthServiceSupabaseImpl is the implementation and lives in :data. A feature asks for authentication without knowing about Supabase.
:domain is kept as pure Kotlin: no Compose, no Koin, no Ktor, no Supabase. That way it compiles for every target with nothing extra, and its tests run fast.
In practice the line between domain and data isn't always absolute. Being too strict easily leads to over-engineering. Weigh maintainability against how fast you ship features.
Part 7: UI Ownership
:feature:*owns the product flows: menu, game, settings, leaderboard, onboarding.:ui-componentsowns reusable building blocks: buttons, dialogs, auth form components, game answer options, menu widgets.:themeowns color, typography, spacing, shape, elevation and motion.
A composable that only makes sense in one flow stays in that feature module. If several flows need it, move it into :ui-components.
Part 8: How A Module Is Declared
Open feature/game/build.gradle.kts:
plugins {
alias(libs.plugins.kta.kmp.library)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.kotlin.compose)
}
kta.kmp.library isn't a Google or JetBrains plugin. It's the repo's own convention plugin, the KmpLibraryConventionPlugin class in build-logic/. It's applied to every module except :app, and it sets up once for all of them:
- the Kotlin Multiplatform plugin and AGP 9's Android KMP library plugin,
jvmToolchain(17),compileSdk,minSdk,- the Android target plus the two iOS targets
iosArm64()andiosSimulatorArm64(), - Android host tests (
withHostTest), which is where thetestAndroidHostTesttask comes from, - detekt.
That's why each module's build file is so short: it declares a namespace in kotlin { androidLibrary { ... } } and its dependency list. UI modules add the two Compose plugins.
build-logic/ still has the kta.android.library, kta.android.feature and kta.android.compose plugins from when the project was plain Android. No module uses them now. When you read an unfamiliar project, check which plugins the build files actually apply; don't infer it from what exists in build-logic/.
To understand how every module is configured, read KmpLibraryConventionPlugin once. That's the main benefit of a convention plugin, and you will see the same pattern in most multi-module KMP projects.
Part 9: Classify Code Before You Add It
Before you add new code, ask yourself:
- Is this a platform's entry point?
:appfor Android,iosApp/for iOS. Keep both very thin. - Is this app-wide shared wiring (startup, the feature list, root navigation)?
:shared-app. - Is this a technical contract shared between modules?
:core, or:kernelif it sits at the lowest level. - Is this a business rule or a use case?
:domain. - Is this an implementation that calls an outside service?
:data. - Is this a reusable UI primitive?
:ui-components. - Is this a product flow or a screen? The matching
:feature:*. - Is this a visual token?
:theme. - Is this secret handling? Behind
:secrets.
Once you have picked the module, ask one more question: does this code call an SDK that only one platform has? If not, put it in commonMain. If it does, put the contract in commonMain and the implementation in androidMain/iosMain.
This checklist prevents a common mistake: dropping everything into :app because it's easy to find. In a KMP project, code dropped into :app is code iOS never sees.
Checkpoint
Open settings.gradle.kts, then open one file each from :app, :shared-app, :domain, :data and one :feature:* module.
You are done with this lesson when you can explain in your own words:
- why
scripts/andiosApp/aren't insettings.gradle.kts, - why the feature list lives in
:shared-appinstead of:app, - which Gradle module the leaderboard screen lives in,
- why both
DataModule.android.ktandDataModule.ios.ktexist, - why the feature modules' build files are so short.