# Read The Module Graph

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

- URL: https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-01-read-module-graph
- Course: [Mobile Action Pro](https://dantech.academy/en/courses/kotlin-accelerator.md)
- Module: Production App Map Architecture
- Duration: 6 min
- Difficulty: beginner
- Access: Free, full lesson
- Tiếng Việt: https://dantech.academy/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-01-read-module-graph.md

## Objectives

- 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

---

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

```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
```

```mermaid
%%{init: {"theme": "base", "themeVariables": {"background": "transparent", "primaryColor": "#1e293b", "primaryTextColor": "#e2e8f0", "primaryBorderColor": "#475569", "secondaryColor": "#1e293b", "tertiaryColor": "#0f172a", "lineColor": "#64748b", "textColor": "#e2e8f0", "clusterBkg": "#0f172a", "clusterBorder": "#475569", "titleColor": "#cbd5e1", "edgeLabelBackground": "#334155", "fontSize": "15px"}, "flowchart": {"wrappingWidth": 420}}}%%
flowchart TB
    classDef ui fill:#1e293b,stroke:#3b82f6,stroke-width:2px,color:#e2e8f0
    classDef vm fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#e2e8f0
    classDef dom fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#e2e8f0
    classDef data fill:#1e293b,stroke:#8b5cf6,stroke-width:2px,color:#e2e8f0
    classDef root fill:#1e293b,stroke:#94a3b8,stroke-width:2px,color:#e2e8f0
    classDef bad fill:#1e293b,stroke:#ef4444,stroke-width:2px,color:#e2e8f0
    classDef good fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#e2e8f0
    classDef note fill:#0f172a,stroke:#475569,stroke-dasharray:4 3,color:#cbd5e1
    accTitle: The repo module graph
    accDescr: An arrow means depends on. app depends on shared-app. shared-app depends on the three feature modules and data. Features depend on domain, core, kernel, theme and ui-components, not on data. data depends on domain, core, kernel and secrets. domain depends only on dre-core.

    APP[":app<br/>Android"]:::root --> SHARED[":shared-app<br/>shared shell, feature list"]:::root
    FEAT[":feature:game<br/>:feature:menu<br/>:feature:paywall"]:::ui
    SHARED --> FEAT
    SHARED --> DATA[":data"]:::data
    FEAT --> DOMAIN[":domain"]:::dom
    DATA --> DOMAIN
    subgraph UIB["UI foundation"]
        UIC[":ui-components"]:::ui --> THEME[":theme"]:::ui
    end
    FEAT --> UIB
    FEAT --> CORE[":core"]:::root
    FEAT --> KERNEL[":kernel"]:::root
    DATA --> CORE
    DATA --> KERNEL
    DATA --> SECRETS[":secrets"]:::root
    CORE --> KERNEL
    FEAT --> DREVM[":dre-viewmodel"]:::vm
    DREVM --> DRECORE[":dre-core"]:::vm
    DOMAIN --> DRECORE
    DRECORE ~~~ NOTE["Arrow = depends on.<br/>There is no Feature -> :data arrow.<br/>Left out: :app also depends directly on :domain, :secrets;<br/>:shared-app also depends directly on the foundation modules;<br/>:ui-components uses :core;<br/>only :feature:game and :feature:menu use :dre-*."]:::note
```

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 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/`:

```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`

| 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-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.

---

- Previous lesson: [Run The App On iOS (macOS + Xcode)](https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/00-setup-app-running/lesson-06-run-app-on-ios.md)
- Next lesson: [Startup Flow On Android And iOS](https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-02-trace-app-startup.md)
