- 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
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.
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
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:
: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:
- Koin modules that create the feature's ViewModels and helpers.
- A navigation registration so the root
NavHostknows which screen to show.
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:
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:
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:
interface FeatureModuleInitializer {
val koinModules: List<Module>
}
FeatureRegistry.koinModules(initializers) merges every initializer's koinModules into one list. It requires 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:
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:
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:
- Each feature has a
FeatureNavigationProviderthat returns aFeatureNavigationRegistrar. - The registrar knows how to add the feature's routes to a
NavGraphBuilder. AppNavHostcallsFeatureNavigationRegistry.registrars(appFeatureNavigationProviders).- Inside the
NavHost, each registrar callsregisterComposable(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:
@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
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).
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
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:menudoesn't importGameScreen. You can rewrite the entire game UI without changing a single line in the menu.:feature:menudoesn't create its ownNavController. There is only oneNavHostController, at the root, so the back stack doesn't get tangled.:feature:menudoesn't know the game has its own transitions.GameNavigation.ktdeclaresenterTransitionandpopExitTransitionforGameRouteDataitself.
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
- Create the module with
alias(libs.plugins.kta.kmp.library), add the two Compose plugins if it has UI, and setnamespaceinkotlin { androidLibrary { ... } }. - Add
include(":feature:<name>")tosettings.gradle.kts. - Declare the feature's dependencies:
:core,:domain,:theme,:ui-components... Don't depend on another feature. - Add
implementation(project(":feature:<name>"))toshared-app/build.gradle.kts. - Create an
internalKoin module and a class that implementsFeatureModuleInitializer. - Add route data to
Routes.ktin:coreif other modules need to navigate to the feature. - Implement
FeatureNavigationProvider. - Add the initializer and the provider to the two lists in
AppFeatures.kt. - 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
GameRouteDatalive 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?