š§± Breaking the Monolith: A Practical, Step-by-Step Guide to Modularizing Your Android AppāāāPartĀ 3
Navigation and Feature Isolation

š 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:
HomeScreenexplicitly 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 inHomeScreen, and every other feature navigating to Detail.Lack of Clear Ownership: The central
NavHostbecomes 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 (
:appmodule): 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:
HomeScreenandHomeViewModelreside in:feature-home.DetailScreenandDetailViewModelreside in:feature-detail.
Core Logic Modules Extracted:
:core-uifor common, reusable UI components and theming (e.g.,TopAppBarConfig,Shape.kt,Color.kt,Theme.kt).:core-domainfor business logic and models (e.g.,Image.kt,GetImagesUseCase.kt).:core-datafor data access layers (e.g.,ImageRepositoryImpl.kt,DataModule.kt).:core-platform-utilsfor system-level helpers (e.g.,NotificationHelper.kt,NetworkMonitor.kt).:core-commonfor 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-navigationfor 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-navigationbecomes 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 upheldNo risk of breaking another featureās navigation.
New features can be added without touching existing onesāonly
:apporchestration 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-ktxImplications 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.libraryandorg.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/navigationpackage in:appmodule and decide if we should move any of the files to:core-navigationor keep in the:appmodule:AppRoute.kt:Defines route strings, argument keys, and potentially helper functions for building routes. This is exactly the role we've identified for
AppRoutes.ktwithin the:core-navigationmodule.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.ktwithin each graph. In the future, we are going to decouple theAppRoute.ktto be more scalable.
AppNavHost.kt:Contains the main
NavHostComposable for the application, responsible for setting up theNavControllerand defining the top-level navigation graph by calling the graph builder extension functions from various features.Decision: Stays in the
:appmodule.
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
:appmodule (e.g., in auiorcommon/uipackage 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-uicould be a place.
MainNavGraphBuilder.kt:It defines a nested navigation graph (using
navigation(...)) that includesHomeScreenandDetailScreen.Decision: Stays in the
:appmodule.
ProfileNavGraph.kt:An extension function on
NavGraphBuilderthat defines the navigation graph for the profile feature (e.g.,navigation(startDestination = ..., route = AppRoutes.PROFILE_GRAPH) { ... }).Decision: Keep in
:appmodule and move to:feature-profilein 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:
homeGraphno longer takes aNavController. Instead, it accepts anIDetailNavigator.HomeScreenwill use this interface to request navigation.
- Key Change:
: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:
detailNavGraphstill acceptsNavControllerbecause it's defining a destination within the Jetpack Navigation framework.DetailScreenmight also use it forpopBackStackor returning results, which are framework-level interactions. The crucial decoupling happens when other features navigate to Detail, not for Detail's internal setup.
- Rationale for NavController:
š„ :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:
mainAppGraphcalls the graph builder functions from:feature-homeand:feature-detail, passing the necessary dependencies. ForhomeGraph, it passes theIDetailNavigatorimplementation.
- Orchestration:
:app/common/navigation/AppNavHost.kt&:app/ui/AppWithTopBar.kt: These files set up theNavHostController, instantiateAppNavigatorImpl, and callmainAppGraph.
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) } // ... }HomeScreenhas no knowledge ofNavControllerfor this action, nor does it know about "DetailScreen" or its module. It simply invokes a method on an abstractIDetailNavigatorinterface.
š 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-navigationmodule has its own owner (perhaps a platform or architecture team) and evolves with input from feature teams. The:appmodule team focuses on integration and app-level concerns.Reduced Cognitive Load: Developers working on
:feature-homeonly need to understandIDetailNavigator, not the entire app's navigation map or the internals of:feature-detail.Enhanced Testability:
HomeScreencan be unit-tested by providing a mockIDetailNavigator.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-homeare 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
:appmodule'sAppNavigatorImpland the feature graph builders, as long as the:core-navigationcontracts remain stable.
ā Wrap-up: Achieving True Feature Isolation
By establishing clear navigation boundaries with
:core-navigationas the contract layer, feature-owned navigation graphs, and the:appmodule 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.
I'd also love to hear from you! What's your experience with modularizing the Android app? Let me know in the comments below š




