# Feature Registration And Navigation Boundaries

> Understand how the app knows which features exist through an explicit list, and how it navigates between them without coupling them.

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

## Objectives

- Trace the two registration paths: appFeatureInitializers loads Koin modules and appFeatureNavigationProviders loads routes
- Explain why features request navigation through callbacks instead of importing other features
- Explain why reflection can't be used in a KMP project and how AppFeaturesTest takes over the checking role

---

## Lesson Goal

The opening question: where does `AppNavHost` get the menu screen from?

This lesson answers two questions: how the app knows which features exist, and how one feature opens another without turning them into dependencies of each other. You will also see why the repo dropped "discover features automatically with reflection" when it moved to Kotlin Multiplatform, a lesson that applies to every KMP project.

## Start With The Big Picture

The app has three feature modules:

```text
:feature:game
:feature:menu
:feature:paywall
```

`:feature:paywall` follows exactly the mechanism below, but the premium gate is off by default, so this lesson uses `game` and `menu` as the examples.

For a feature to show up in the app, it has to contribute two things:

1. Koin modules that create the feature's ViewModels and helpers.
2. A navigation registration so the root `NavHost` knows which screen to show.

```text
feature module
-> provides a FeatureModuleInitializer (Koin modules)
-> provides a FeatureNavigationProvider (routes)
-> :shared-app lists both in AppFeatures.kt
-> the feature works on Android and iOS
```

## Part 1: An Explicit Feature List

Open `shared-app/src/commonMain/kotlin/com/creative/kta/shared/AppFeatures.kt`:

```kotlin
val appFeatureInitializers: List<FeatureModuleInitializer> = listOf(
    GameFeatureInitializer(),
    MenuFeatureInitializer(),
    PaywallFeatureInitializer(),
)

val appFeatureNavigationProviders: List<FeatureNavigationProvider> = listOf(
    GameNavigationProvider(),
    MenuNavigationProvider(),
    PaywallNavigationProvider(),
)
```

These two lists are the app's entire statement of which features exist. Adding a feature means adding one line to each list.

The file lives in `:shared-app` because it's the only module that depends on every feature, and both Android and iOS go through it. `:app` doesn't depend on any feature.

## Part 2: The First Path, Koin Modules

Open:

```text
core/src/commonMain/kotlin/com/creative/kta/core/di/FeatureModuleInitializer.kt
core/src/commonMain/kotlin/com/creative/kta/core/di/FeatureRegistry.kt
```

The contract is tiny:

```kotlin
interface FeatureModuleInitializer {
    val koinModules: List<Module>
}
```

`FeatureRegistry.koinModules(initializers)` merges every initializer's `koinModules` into one list. It `require`s that the list isn't empty and has no duplicate initializers, so a configuration mistake blows up at startup instead of failing silently. `appKoinModules` in `AppKoinModules.kt` calls this function with `appFeatureInitializers`.

Open `feature/game/src/commonMain/kotlin/com/creative/kta/feature/game/di/GameFeatureModule.kt` and `feature/menu/.../menu/di/MenuFeatureModule.kt`:

| Feature | Provides                                                                                                                |
| ------- | ----------------------------------------------------------------------------------------------------------------------- |
| `game`  | `GameTimer`, `LifelineHelper`, `GameOverlayQueueManager`, `GameFeedbackController`, `GameStateMachine`, `GameViewModel` |
| `menu`  | `MenuViewModel`, `SettingViewModel`, `LeaderboardViewModel`, `MenuOnboardingViewModel`                                  |

At the end of `GameFeatureModule.kt`:

```kotlin
class GameFeatureInitializer : FeatureModuleInitializer {
    override val koinModules: List<Module> = listOf(gameFeatureModule)
}
```

`gameFeatureModule` is `internal`, so nothing outside `:feature:game` can touch it. The only thing the feature exposes is the initializer. The feature's ViewModels live in the feature module, so when a ViewModel can't be injected, you know right away which feature is responsible.

`:feature:menu` holds four sub-features (menu, leaderboard, settings, onboarding), so `MenuFeatureModule` provides four ViewModels. One Gradle module, one initializer, several flows.

## Part 3: The Second Path, Navigation

Open `core/src/commonMain/kotlin/com/creative/kta/core/navigation/FeatureNavigationRegistry.kt`. The file holds three contracts:

```kotlin
interface FeatureNavigationRegistrar {
    fun NavGraphBuilder.registerComposable(callbacks: FeatureNavigationCallbacks)
}

interface FeatureNavigationProvider {
    val featureNavigationRegistrar: FeatureNavigationRegistrar
}
```

plus `FeatureNavigationCallbacks` from Part 5, and the `FeatureNavigationRegistry` object whose `registrars(providers)` function checks for empty and duplicate entries.

How it works:

1. Each feature has a `FeatureNavigationProvider` that returns a `FeatureNavigationRegistrar`.
2. The registrar knows how to add the feature's routes to a `NavGraphBuilder`.
3. `AppNavHost` calls `FeatureNavigationRegistry.registrars(appFeatureNavigationProviders)`.
4. Inside the `NavHost`, each registrar calls `registerComposable(navigationCallbacks)`.

The providers live in `GameNavigation.kt` and `MenuNavigation.kt`, in each feature's `navigation` package. The feature owns how its destinations are registered. `AppNavHost` owns the single `NavController`.

## Part 4: Route Data Lives In `:core`

Open `core/src/commonMain/kotlin/com/creative/kta/core/navigation/Routes.kt`:

```kotlin
@Serializable
data object MenuRouteData

@Serializable
data object GameRouteData

@Serializable
data object PaywallRouteData
```

They are `@Serializable` because the app uses Navigation Compose's typed navigation (JetBrains' multiplatform version) instead of string routes.

`:core` is where route contracts that several modules need to understand belong. `:feature:menu` needs to know that `GameRouteData` exists, but not what `GameScreen` looks like.

## Part 5: Callbacks Keep Features From Depending On Each Other

```kotlin
interface FeatureNavigationCallbacks {
    fun navigateToMenu()
    fun navigateToGame(category: String? = null, isPractice: Boolean = false)
    fun navigateToDailyGame()
    fun navigateToPaywall()
    fun navigateBack()
}
```

A feature only states its intent. The root host decides how to carry it out. That part lives in `shared-app/src/commonMain/kotlin/com/creative/kta/shared/navigation/AppNavigationCallbacks.kt`: the `AppNavigationCallbacks` class implements this contract, and `AppNavHost` creates it through `rememberNavigationCallbacks(navController, premiumGate)`.

```kotlin
override fun navigateToGame(category: String?, isPractice: Boolean) {
    navController.navigate(GameRouteData)
}
```

`navigateToPaywall` is also the only place that checks `PremiumGateConfig.isEnabled`. With the gate off, the callback ignores the navigation request. One check covers every button that opens the paywall, on both platforms.

### Tracing One Screen Change

```text
User taps Play on the Menu
-> MenuViewModel, through MenuNavigationSideEffectHandler, emits MenuUiEvent.NavigateToGame
-> MenuRoute calls onNavigateToGame(...)
-> MenuNavigationProvider has wired onNavigateToGame = callbacks::navigateToGame
-> AppNavigationCallbacks.navigateToGame runs navController.navigate(GameRouteData)
-> GameNavigationProvider has registered composable<GameRouteData>
-> GameRoute renders the game
```

What doesn't happen in this flow:

- `:feature:menu` doesn't import `GameScreen`. You can rewrite the entire game UI without changing a single line in the menu.
- `:feature:menu` doesn't create its own `NavController`. There is only one `NavHostController`, at the root, so the back stack doesn't get tangled.
- `:feature:menu` doesn't know the game has its own transitions. `GameNavigation.kt` declares `enterTransition` and `popExitTransition` for `GameRouteData` itself.

## Part 6: Why The Repo Dropped Reflection

Before the move to KMP, the app found features with reflection: `FeatureRegistry` held class names as strings and called `Class.forName(...)`, with keep rules in `app/proguard-rules.pro` so R8 wouldn't strip those classes.

That approach had three problems:

| Problem                              | Consequence                                                                                      |
| ------------------------------------ | ------------------------------------------------------------------------------------------------ |
| The compiler doesn't check strings   | Rename `GameFeatureInitializer` and the string keeps the old name. The code compiles cleanly and the feature disappears at runtime |
| R8 sees nobody calling the class     | Miss a keep rule and the release build loses the feature while debug still works                 |
| Kotlin/Native has no `Class.forName` | On iOS this approach can't run at all                                                            |

The third problem decides it. Code in `commonMain` can't use the JVM's reflection APIs. The explicit list in `AppFeatures.kt` fixes all three: the compiler checks the class names, R8 sees the classes referenced and keeps them without any keep rule, and the code behaves the same on every platform.

The lesson you can take with you: when a mechanism depends on the JVM (reflection, `ServiceLoader`, runtime annotation processing), it doesn't follow you to Kotlin/Native. In a KMP project, prefer explicit wiring the compiler can check.

The price of an explicit list is that if you forget a line, the feature doesn't show up. `AppFeaturesTest` (`shared-app/src/commonTest/`) guards against that: it checks that each list has exactly three entries, that they contain game, menu and paywall, and that no entry is duplicated.

## Part 7: Debug By Behavior

| Behavior                                           | Where to check first                                                  |
| -------------------------------------------------- | --------------------------------------------------------------------- |
| A ViewModel can't be injected                      | The feature's Koin module, `appFeatureInitializers`, then `AppGraphTest` |
| A screen never appears                             | The feature's `*Navigation.kt`, then `appFeatureNavigationProviders`  |
| The app crashes at startup with a duplicate or empty error | A `require` in `FeatureRegistry` or `FeatureNavigationRegistry` just failed |
| Tapping a paywall button does nothing              | The premium gate is off; see `navigateToPaywall` in `AppNavigationCallbacks.kt` |
| Another feature can only open a route by importing its UI | Replace the direct import with a callback plus a route contract in `:core` |

## Part 8: Checklist For Adding A New Feature

1. Create the module with `alias(libs.plugins.kta.kmp.library)`, add the two Compose plugins if it has UI, and set `namespace` in `kotlin { androidLibrary { ... } }`.
2. Add `include(":feature:<name>")` to `settings.gradle.kts`.
3. Declare the feature's dependencies: `:core`, `:domain`, `:theme`, `:ui-components`... Don't depend on another feature.
4. Add `implementation(project(":feature:<name>"))` to `shared-app/build.gradle.kts`.
5. Create an `internal` Koin module and a class that implements `FeatureModuleInitializer`.
6. Add route data to `Routes.kt` in `:core` if other modules need to navigate to the feature.
7. Implement `FeatureNavigationProvider`.
8. Add the initializer and the provider to the two lists in `AppFeatures.kt`.
9. Update `AppFeaturesTest`, then run `./gradlew :shared-app:testAndroidHostTest`.

Skip step 4 and `AppFeatures.kt` doesn't compile. Skip step 8 and the feature never appears, and only the `AppFeaturesTest` you updated in step 9 catches it.

## Anti-Patterns

| Anti-pattern                                       | Why it hurts                                                     |
| -------------------------------------------------- | ---------------------------------------------------------------- |
| Importing another feature's screen                 | Couples the features together                                    |
| Writing every route registration in `AppNavHost`   | `:shared-app` grows every time a feature changes                 |
| Passing a `NavController` into every screen        | The UI knows too much about navigation                           |
| Building string routes in several files            | Loses the type checking of typed navigation                      |
| Putting the feature list in `:app`                 | iOS has no `:app`, so iOS would have no features                 |
| Finding features with reflection in shared code    | Not available on Kotlin/Native, and the compiler can't check it  |

## Checkpoint

You understand this lesson when you can answer:

- Which class provides the game's Koin bindings, and which file puts it into the graph?
- Which class registers the game's navigation, and which function calls it?
- Which module does `GameRouteData` live in, and why isn't it in `:feature:game`?
- How does the menu open the game without importing any game class?
- Why can't this project use `Class.forName`, and what replaces it?

---

- Previous 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)
- Next lesson: [Layer Responsibilities And How To Read File Names](https://dantech.academy/en/courses/kotlin-accelerator/engineer-builder/01-production-app-map/lesson-04-spot-layer-responsibilities.md)
