Skip to main content

Command Palette

Search for a command to run...

🧱 Breaking the Monolith: A Practical, Step-by-Step Guide to Modularizing Your Android Appā€Šā€”ā€ŠPartĀ 3

Navigation and Feature Isolation

Updated
•13 min read
🧱 Breaking the Monolith: A Practical, Step-by-Step Guide to Modularizing Your Android Appā€Šā€”ā€ŠPartĀ 3

šŸš€ Introduction

In Part 1, we stabilized the domain and extracted our first feature module (feature-bookmarks).
In Part 2, we establish core data and DI boundaries, ensuring features depend only on stable contracts.

But there’s a trap: even if your features are cleanly wired, navigation can still reintroduce coupling. If one feature directly references another (e.g., HomeFragment → DetailFragment), your modularization effort starts to crumble.

That’s why in this part, we’ll focus on navigation boundaries (Navigation between features) —ensuring each feature owns its own navigation and communicates only through contracts. In other words, how do features talk to each other without introducing coupling?

šŸ‘‰ The Core Challenge: Decoupled Inter-Feature Navigation.


šŸŽÆ Why Navigation Boundaries Matter

  • Feature Independence: Each feature must own its own screens and navigation graph.

  • Scalability: Features can be added, removed, or delivered dynamically.

  • Loose Coupling: No feature knows about another—only about shared contracts.

  • Testability: Easier to test navigation flows in isolation.


🤺 The Perils of Monolithic Navigation in Large-Scale Apps

In a simpler, monolithic application, navigation might be handled directly within a single NavHost:

// Monolithic (and problematic for scale)
NavHost(navController, startDestination = "home") {
    composable("home") {
        HomeScreen(
            onImageClick = { image -> // Assuming 'image' has an 'id'
                navController.navigate("detail/${image.id}")
            }
        )
    }
    composable("detail/{id}") { backStackEntry ->
        DetailScreen(imageId = backStackEntry.arguments?.getString("id"))
    }
    // ... many other composable routes for other features
}

While functional for small projects, this approach quickly erodes in a large application:

  • Tight Coupling: HomeScreen explicitly knows about the route string and argument structure of the "Detail" feature. Any change to the Detail route (e.g., adding a new required argument) could necessitate changes in HomeScreen, and every other feature navigating to Detail.

  • Lack of Clear Ownership: The central NavHost becomes a massive file, a common source of merge conflicts, and its logic becomes difficult to reason about as features are added or modified by different teams.

  • Impeded Parallel Development: Teams cannot work on their features' navigation logic in true isolation.

  • Difficult Refactoring: Changing navigation patterns or replacing a feature becomes a high-risk endeavor.

Our goal is to evolve towards true vertical slices, where features are self-contained and interact through well-defined, stable contracts, not direct dependencies.


āš–ļø Our Guiding Principles for Scalable Navigation

To achieve this, we adopt several core principles:

  • Feature-Owned Navigation Graphs: Each feature module is responsible for defining its own navigation graph, detailing its entry points and internal composable structure.

  • No Direct Inter-Feature References: A feature module (e.g., :feature-home) must never directly import or reference code (UI, ViewModels, internal navigation logic) from another feature module (e.g., :feature-detail).

  • Shared Navigation Contracts (:core-navigation): A dedicated, lean module (:core-navigation) defines all inter-feature navigation contracts. This includes:

    • Route Definitions: Standardized route strings and argument keys.

    • Navigator Interfaces: Abstract contracts for navigation actions (e.g., interface IDetailNavigator { fun navigateToDetail(imageId: String) }).

  • Application-Level Orchestration (:app module): The main application module (:app) is responsible for:

    • Implementing the navigator interfaces from :core-navigation.

    • Assembling the individual feature navigation graphs into the complete application navigation flow.


🪨 Preparing the Ground: Foundational Modularity (Current State)

Our journey to decoupled navigation is significantly aided by foundational modularity already in place in your codebase:

  • Feature Modules Established:

    • HomeScreen and HomeViewModel reside in :feature-home.

    • DetailScreen and DetailViewModel reside in :feature-detail.

  • Core Logic Modules Extracted:

    • :core-ui for common, reusable UI components and theming (e.g., TopAppBarConfig, Shape.kt, Color.kt, Theme.kt).

    • :core-domain for business logic and models (e.g., Image.kt, GetImagesUseCase.kt).

    • :core-data for data access layers (e.g., ImageRepositoryImpl.kt, DataModule.kt).

    • :core-platform-utils for system-level helpers (e.g., NotificationHelper.kt, NetworkMonitor.kt).

    • :core-common for widely shared, simple utilities or constants.

This robust separation ensures that when we introduce :core-navigation, it remains lean, focused purely on navigation contracts, and free from UI, domain, or platform-specific baggage. It has no dependencies on feature modules.


šŸŖ— Defining :core-navigation: The Contract Layer

:core-navigation is a Pure Kotlin module (no Android dependency), and it is a dedicated module whose sole purpose is to define navigation contracts between features in a modularized Android app.

  • It contains route definitions (constants, sealed classes, typed arguments).

  • It provides a common language, so features and the app module can communicate without direct dependencies.

  • It is dependency-free (no feature references, no Android UI components).

  • Think of it as the protocol of your app’s navigation system.

šŸŽÆ Responsibilities of :core-navigation

  • Centralize and type-safe for all navigation route definitions and their arguments:

    • Each screen (or feature entry point) gets a unique route string.

    • Encapsulates arguments, so they’re consistent everywhere.

// In :core-navigation/AppRoutes.kt
object AppRoutes {
    object Main {
        object Home {
            const val route = "main_home"
        }
        object Detail {
            const val ARG_IMAGE_ID = "imageId"
            val route = "main_detail/{$ARG_IMAGE_ID}" // Route definition
            fun createRoute(imageId: String) = "main_detail/$imageId"
        }
    }
    // ... other app graphs or routes
}
  • Act as contracts / Navigator interfaces

    • Abstract contracts for specific navigation actions. This is crucial for decoupling the intent to navigate from the mechanism of navigation

    • Features know only about routes, not about other features.

    • Prevents ā€œstringly-typed navigationā€ scattered across modules.

// In :core-navigation/navigators/IDetailNavigator.kt
package com.vsay.pintereststylegriddemo.core.navigation.navigators

interface IDetailNavigator {
    fun navigateToDetail(imageId: String)
}
  • Promote consistency

    • One source of truth for navigation keys/args.

    • Reduces bugs from typos or mismatched arguments.

  • Enable scalability

    • Makes it possible to plug/unplug features easily.

    • Works for static modules and dynamic feature delivery.

🧩 Roles of :core-navigation in Modularization

  • For Features (:feature_*)

    • Features depend on :core-navigation for route constants.

    • A feature declares its own NavGraph builder but uses route contracts defined in :core-navigation.

    • Other modules (feature modules, app module) will consume these contracts to navigate or to define destinations that can be navigated to

  • For :app (or orchestration layer)

    • The app module is the glue.

    • It ties together NavGraphs from features using the shared contracts.

    • Example: bookmarks → detail is mapped at the app level, not inside either feature.

  • For Big Teams

    • Clear separation:

      • The feature team only implements its own screens. :core-navigation becomes the stable API for inter-feature navigation. Feature teams (Squad A for Home, Squad B for Detail) can develop in parallel, relying on these established contracts. Changes to a feature's internal UI or state management do not break other features as long as the navigation contracts are upheld

      • No risk of breaking another feature’s navigation.

    • New features can be added without touching existing ones—only :app orchestration changes.

    • Teams can evolve navigation independently as long as they stick to the shared contracts.

āš–ļø Should :core-navigation be a Pure Kotlin or Android module?

What is "Best Practice" in this Context?
There isn't a single, universally mandated "best practice" that dictates one choice over the other without considering the trade-offs. It often comes down to what a team prioritizes:

  • Pure Kotlin: Favored when absolute priority is module purity and minimizing Android framework leakage.

  • Android Library: Chosen when priority is maximum explicitness and type safety in the navigation contract.

In this article, Pure Kotlin Module is a sound architectural choice because the primary role of :core-navigation is strictly route strings and argument keys, and the type of those arguments can be managed by the feature modules that own the destinations. It aligns with keeping the module as lean and decoupled from Android as possible.

Here are closer looks at the two options:

:core-navigation as a Pure Kotlin/JVM Module

  • If the absolute priority is module purity and minimizing any Android framework leakage into "core" non-UI modules, then Pure Kotlin is often favored. The cost is a slightly less explicit contract regarding argument types.

  • Definition: Applies only org.jetbrains.kotlin.jvm (via our KotlinLibraryConventionPlugin)

  • Responsibilities Fulfilled:

    • Can define route strings (e.g., const val MY_SCREEN_ROUTE = "my_screen/{itemId}")

    • Can define argument keys (e.g., const val ARG_ITEM_ID = "itemId")

  • Dependencies: Cannot directly depend on AARs like androidx.navigation:navigation-common-ktx

  • Implications for Argument Types (NavType):

    • :core-navigation cannot define the NavType (e.g., NavType.StringType, NavType.IntType) for arguments directly

    • The responsibility of specifying the NavType for an argument shifts to the module that owns the Composable destination (typically a feature module)

  • Pros:

    • Strictly adheres to Pure Kotlin: No Android framework dependency. Lightest possible module

    • Fastest Compile Times: For this specific module

  • Cons:

    • Decentralized Argument Type Definition: The full contract (route + arg keys + arg types) is not 100% centralized

    • Potential for Inconsistency: A feature module could define a different NavType than intended

    • Implicit Contract: The type of the argument becomes an implicit part of the contract

:core-navigation as an Android Library Module

  • If the priority is maximum explicitness and type safety in the navigation contract itself, even if it means a "contracts" module touches parts of an AndroidX library (that isn't UI), then Android Library is often chosen.

  • Definition: Applies com.android.library and org.jetbrains.kotlin.android (via our AndroidLibraryConventionPlugin)

  • Responsibilities Fulfilled:

    • Can define route strings

    • Can define argument keys

    • Can define argument types using NavType and provide pre-configured NamedNavArgument lists

  • Dependencies: Can depend on androidx.navigation:navigation-common-ktx (AAR)

  • Pros:

    • Centralized and Explicit Argument Type Definition: The Entire navigation contract is defined in one place

    • Improved Type Safety & Clarity at Contract Level: Clearer understanding of expected argument types

    • Reduced Boilerplate in Features: Feature modules might use pre-defined argument lists

  • Cons:

    • Not "Pure" Kotlin/JVM: Introduces Android framework dependency

    • Slightly More Overhead: Minimal but present compilation and size impact

šŸ‘· Implement :core-navigation

  • Create a :core-navigation android module

  • Update default content of build.gradle.kts in the :core-navigation module to use koltin plugin created in :convention-plugins module

plugins {
    id("pinterest.kotlin-library-convention")
}

dependencies {

}
  • Let’s examine files in /common/navigation package in :app module and decide if we should move any of the files to :core-navigation or keep in the :app module:

    • AppRoute.kt:

      • Defines route strings, argument keys, and potentially helper functions for building routes. This is exactly the role we've identified for AppRoutes.kt within the :core-navigation module.

      • Decision: Move to :core-navigation.

    • NavArgs.kt:

      • It holds navigation argument keys only.

      • Decision: We are going to remove this file and have the argument created in the AppRoutes.kt within each graph. In the future, we are going to decouple the AppRoute.kt to be more scalable.

    • AppNavHost.kt:

      • Contains the main NavHost Composable for the application, responsible for setting up the NavController and defining the top-level navigation graph by calling the graph builder extension functions from various features.

      • Decision: Stays in the :app module.

    • BottomNavItem.kt:

      • Defines the data structure (e.g., a data class or sealed class) representing items in our main bottom navigation bar (icon, label, route).

      • Decision:

        • Option A (Recommended for now): Stays in :app module (e.g., in a ui or common/ui package within :app). The bottom navigation bar is part of the app's main scaffolding.

        • Option B (If it becomes more complex or shared across apps): Moves to :core-ui. If the definition is purely data (route strings from :core-navigation, drawable res IDs, string res IDs) and doesn't have much logic, and if you had multiple app modules sharing the exact same bottom nav structure, then :core-ui could be a place.

    • MainNavGraphBuilder.kt:

      • It defines a nested navigation graph (using navigation(...)) that includes HomeScreen and DetailScreen.

      • Decision: Stays in the :app module.

    • ProfileNavGraph.kt:

      • An extension function on NavGraphBuilder that defines the navigation graph for the profile feature (e.g., navigation(startDestination = ..., route = AppRoutes.PROFILE_GRAPH) { ... }).

      • Decision: Keep in :app module and move to :feature-profile in the future.


⛲ Feature-Owned Navigation Graphs: Encapsulating Feature Entry Points

Each feature module defines extension functions on NavGraphBuilder to declare its part of the navigation graph.

  • :feature-home/navigation/HomeNavGraph.kt:

      package com.vsay.pintereststylegriddemo.feature_home.navigation
    
      // ... imports (NavGraphBuilder, composable, TopAppBarConfig, AppRoutes, IDetailNavigator, HomeScreen)
    
      fun NavGraphBuilder.homeGraph(
          detailNavigator: IDetailNavigator, // Injected dependency for navigation
          onShowTopAppBar: (TopAppBarConfig) -> Unit,
          @DrawableRes smallIconResId: Int
      ) {
          composable(AppRoutes.Main.Home.route) {
              HomeScreen(
                  detailNavigator = detailNavigator, // Passed to the screen
                  onShowTopAppBar = onShowTopAppBar,
                  smallIconResId = smallIconResId
              )
          }
      }
    
    • Key Change: homeGraph no longer takes a NavController. Instead, it accepts an IDetailNavigator. HomeScreen will use this interface to request navigation.
  • :feature-detail/navigation/DetailNavGraph.kt:

      package com.vsay.pintereststylegriddemo.feature_detail.navigation
    
      // ... imports (NavGraphBuilder, NavController, composable, navArgument, AppRoutes, DetailScreen, TopAppBarConfig)
    
      fun NavGraphBuilder.detailNavGraph(
          navController: NavHostController, // For NavGraphBuilder context & framework interactions
          onShowTopAppBar: (TopAppBarConfig) -> Unit
      ) {
          composable(
              route = AppRoutes.Main.Detail.route,
              arguments = listOf(navArgument(AppRoutes.Main.Detail.ARG_IMAGE_ID) { type = NavType.StringType })
          ) {
              // DetailViewModel will access imageId via SavedStateHandle
              DetailScreen(
                  onShowTopAppBar = onShowTopAppBar
                  // navController can be passed if DetailScreen needs to pop or return results
              )
          }
      }
    
    • Rationale for NavController: detailNavGraph still accepts NavController because it's defining a destination within the Jetpack Navigation framework. DetailScreen might also use it for popBackStack or returning results, which are framework-level interactions. The crucial decoupling happens when other features navigate to Detail, not for Detail's internal setup.

🄁 :app Orchestrates, It Doesn't Dictate Internals

The :app module is where everything comes together.

  • :app/navigation/AppNavigatorImpl.kt: This class implements the navigator interfaces from :core-navigation.

      package com.vsay.pintereststylegriddemo.navigation // In :app module
    
      import androidx.navigation.NavHostController
      import com.vsay.pintereststylegriddemo.core.navigation.AppRoutes
      import com.vsay.pintereststylegriddemo.core.navigation.navigators.IDetailNavigator
      // ... other navigator interface imports
    
      class AppNavigatorImpl(private val navController: NavHostController) : IDetailNavigator /*, other interfaces */ {
          override fun navigateToDetail(imageId: String) {
              navController.navigate(AppRoutes.Main.Detail.createRoute(imageId))
          }
          // ... implementations for other navigator interfaces
      }
    
  • :app/common/navigation/MainAppNavGraph.kt (or similar):

      package com.vsay.pintereststylegriddemo.common.navigation // In :app module
    
      // ... imports (NavGraphBuilder, NavController, navigation, AppViewModel, AppRoutes, IDetailNavigator, homeGraph, detailNavGraph)
    
      fun NavGraphBuilder.mainAppGraph(
          navController: NavHostController, // For graphs that might still need it directly
          appViewModel: AppViewModel,
          detailNavigator: IDetailNavigator // The concrete AppNavigatorImpl is passed here, cast to the interface
      ) {
          navigation(
              startDestination = AppRoutes.Main.Home.route,
              route = AppRoutes.MainAppGraph.route // A nested graph route for this section
          ) {
              homeGraph(
                  detailNavigator = detailNavigator,
                  onShowTopAppBar = { config -> appViewModel.showTopAppBar(config) },
                  smallIconResId = com.vsay.pintereststylegriddemo.R.mipmap.ic_launcher // App-specific resource
              )
              detailNavGraph(
                  navController = navController, // Detail's graph builder uses NavController context
                  onShowTopAppBar = { config -> appViewModel.showTopAppBar(config) }
              )
              // ... other feature graphs, e.g., bookmarksNavGraph(...)
          }
      }
    
    • Orchestration: mainAppGraph calls the graph builder functions from :feature-home and :feature-detail, passing the necessary dependencies. For homeGraph, it passes the IDetailNavigator implementation.
  • :app/common/navigation/AppNavHost.kt & :app/ui/AppWithTopBar.kt: These files set up the NavHostController, instantiate AppNavigatorImpl, and call mainAppGraph.

Example: Navigating Home → Detail Without Coupling

  • Inside :feature-home/ui/HomeScreen.kt:

      // ...
      @Composable
      fun HomeScreen(
          // ... other parameters
          detailNavigator: IDetailNavigator, // Receives the navigator interface
          // ...
      ) {
          // ...
          // When an image is clicked:
          // onClick = { image -> detailNavigator.navigateToDetail(image.id) }
          // ...
      }
    

    HomeScreen has no knowledge of NavController for this action, nor does it know about "DetailScreen" or its module. It simply invokes a method on an abstract IDetailNavigator interface.


šŸ™‹ Why This Architecture Scales for Large Teams & Complex Apps

  • Clear Boundaries & Ownership: Teams own their feature modules, including their internal navigation structure and how they respond to navigation contracts. The :core-navigation module has its own owner (perhaps a platform or architecture team) and evolves with input from feature teams. The :app module team focuses on integration and app-level concerns.

  • Reduced Cognitive Load: Developers working on :feature-home only need to understand IDetailNavigator, not the entire app's navigation map or the internals of :feature-detail.

  • Enhanced Testability:

    • HomeScreen can be unit-tested by providing a mock IDetailNavigator.

    • ViewModel navigation logic can be tested independently of the UI and the Android Navigation framework.

  • Independent Deployability (Future Potential): While not inherently part of this step, such modularity is a prerequisite if you ever explore dynamically delivered features.

  • Improved Build Times: Gradle's incremental builds and build caching work much more effectively with well-defined module boundaries. Changes within :feature-home are less likely to trigger recompilation of :feature-detail.

  • Stability & Maintainability: Changes to the implementation of navigation (e.g., switching to a different navigation library for a section, though unlikely with Jetpack Compose Navigation) could theoretically be confined to the :app module's AppNavigatorImpl and the feature graph builders, as long as the :core-navigation contracts remain stable.


āœ… Wrap-up: Achieving True Feature Isolation

  • By establishing clear navigation boundaries with :core-navigation as the contract layer, feature-owned navigation graphs, and the :app module as the orchestrator, we've achieved a significant milestone.

  • Features like "Home" and "Detail" are now true vertical slices: their UI, state management, and navigation contributions are well-encapsulated.

  • This robust architecture empowers large teams to work efficiently and build a scalable, maintainable application.

For the complete implementation and to explore the code in context, you can find the full project on GitHub: PinterestStyleGridDemo Repository


Thanks for reading! I hope you found this guide on modularizing Android app helpful.

If you enjoyed this, you'd love my other articles.

Subscribe to my free newsletter to get my latest tutorials and coding insights delivered directly to your inbox. No spam, ever. Unsubscribe at any time.

Subscribe Now

I'd also love to hear from you! What's your experience with modularizing the Android app? Let me know in the comments below šŸ‘‡

🧱 Breaking the Monolith: A Practical Guide to Modularizing Your Android App

Part 3 of 4

Breaking the Android monolith: a practical series on modularization. From Gradle setup to domain/data boundaries, feature isolation, and team scaling—everything you need to migrate with confidence.

Up next

🧱 Breaking the Monolith: A Practical, Step-by-Step Guide to Modularizing Your Android Appā€Šā€”ā€ŠPartĀ 4

Dependency Injection Boundaries