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

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.

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

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

FeatureProvides
gameGameTimer, LifelineHelper, GameOverlayQueueManager, GameFeedbackController, GameStateMachine, GameViewModel
menuMenuViewModel, 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:

ProblemConsequence
The compiler doesn't check stringsRename GameFeatureInitializer and the string keeps the old name. The code compiles cleanly and the feature disappears at runtime
R8 sees nobody calling the classMiss a keep rule and the release build loses the feature while debug still works
Kotlin/Native has no Class.forNameOn 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

BehaviorWhere to check first
A ViewModel can't be injectedThe feature's Koin module, appFeatureInitializers, then AppGraphTest
A screen never appearsThe feature's *Navigation.kt, then appFeatureNavigationProviders
The app crashes at startup with a duplicate or empty errorA require in FeatureRegistry or FeatureNavigationRegistry just failed
Tapping a paywall button does nothingThe premium gate is off; see navigateToPaywall in AppNavigationCallbacks.kt
Another feature can only open a route by importing its UIReplace 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-patternWhy it hurts
Importing another feature's screenCouples the features together
Writing every route registration in AppNavHost:shared-app grows every time a feature changes
Passing a NavController into every screenThe UI knows too much about navigation
Building string routes in several filesLoses the type checking of typed navigation
Putting the feature list in :appiOS has no :app, so iOS would have no features
Finding features with reflection in shared codeNot 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?
Sign in