Dan Tech Academy
After this lesson you will
  • 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
Duration6 min
DifficultyBeginner

Read The Module Graph

Read a Kotlin Multiplatform project as a system: modules, dependency direction and source sets.

Video in production

Lesson video is on the way

While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.

Recording is in post-production

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:

text
: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 folderWhat 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.ktsNot 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

ModuleWhat it ownsFile to open first
:appThe Android entry point: Application, Activity, WorkManager, notification channelsApp.kt, LauncherActivity.kt
iosApp/The iOS entry point in Swift, very thiniOSApp.swift, ContentView.swift
:shared-appThe app code shared by both platforms: startup, the feature list, root navigation, Splash. Builds the Shared framework for iOSStartSharedApp.kt, AppFeatures.kt, AppNavHost.kt
:coreContracts shared between modules: route data, feature registries, base types, Compose resourcesFeatureRegistry.kt, Routes.kt
:kernelThe lowest technical foundation, for example dispatchersQzDispatchers.kt
:dre-core, :dre-viewmodelThe in-house state management library: Reducer, SideEffectHandler, DreStoreViewModelReducer.kt, DreStoreViewModel.kt
:domainUse cases, repository and service contracts, business modelsdomain/.../usecase/, domain/.../service/
:dataImplementations of the contracts: Supabase, Ktor, DataStore, RevenueCat...DataModule.kt
:secretsThe boundary for accessing secretsSecretManager.kt
:themeDesign system tokens and the app themeQzdsTheme.kt, QzdsColorTokens.kt
:ui-componentsCompose components shared across screensui-components/.../ui/component/
:feature:*User flows: screens, feature ViewModels, the feature's navigation entryfeature/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:

text
: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:

  • :app doesn't depend on any feature module (except debugImplementation(project(":feature:paywall")) for a debug screen). :app doesn't declare :data either: :data reaches the APK through :shared-app. :shared-app is 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 :data and don't depend on each other. A feature only knows the contracts in :domain and :core. The menu can open the game screen without importing a single game class, thanks to the navigation contract in :core and 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 setWhere it runsExample in the repo
commonMainAndroid and iOSEvery screen, ViewModel and use case, plus DataModule.kt
androidMainAndroid onlyDataModule.android.kt: Firebase, SoundPoolAudioManager
iosMainiOS onlyDataModule.ios.kt: AvAudioManager, NoOpAnalyticsManager
commonTestThe JVM and the iOS SimulatorTests of shared logic
androidHostTestThe JVM on your machineTests 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/:

text
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

NeedModule
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-components owns reusable building blocks: buttons, dialogs, auth form components, game answer options, menu widgets.
  • :theme owns 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:

kotlin
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() and iosSimulatorArm64(),
  • Android host tests (withHostTest), which is where the testAndroidHostTest task 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:

  1. Is this a platform's entry point? :app for Android, iosApp/ for iOS. Keep both very thin.
  2. Is this app-wide shared wiring (startup, the feature list, root navigation)? :shared-app.
  3. Is this a technical contract shared between modules? :core, or :kernel if it sits at the lowest level.
  4. Is this a business rule or a use case? :domain.
  5. Is this an implementation that calls an outside service? :data.
  6. Is this a reusable UI primitive? :ui-components.
  7. Is this a product flow or a screen? The matching :feature:*.
  8. Is this a visual token? :theme.
  9. 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/ and iosApp/ aren't in settings.gradle.kts,
  • why the feature list lives in :shared-app instead of :app,
  • which Gradle module the leaderboard screen lives in,
  • why both DataModule.android.kt and DataModule.ios.kt exist,
  • why the feature modules' build files are so short.
Sign in