Android Multi-Module Navigation: Decouple Feature Modules in Kotlin
A practical architecture guide to Android multi-module navigation in Kotlin, covering API/implementation boundaries, typed routes, dependency injection, deep links, testing, and migration trade-offs.
Table of Contents9 sections

A mobile development workspace representing the composition point where independently built Modularizing A Detail Screen In Android features become one application.
Android multi-module Migrating Navigation Graphs From Activities To Fragments works best when a feature knows where it wants to go without depending on the implementation of the feature it is opening. In practice, that means separating navigation contracts or keys from destination implementations, then composing those implementations at the application boundary.
That distinction matters more than the choice between Activities, Navigation Compose, or Navigation 3. A project can use modern navigation APIs and still be tightly coupled if :feature:checkout must depend directly on :feature:payment just to open a payment screen.
The useful architecture question is therefore not “where should I put my Intent?” It is: which module owns the navigation contract, which module implements the destination, and which module is allowed to connect the two?
The dependency problem behind cross-feature navigation
Imagine this dependency graph:
:feature:checkout ---> :feature:payment
Checkout imports a payment Activity, Fragment, composable route, or navigation graph directly. It works, but the dependency means payment implementation changes can invalidate checkout, isolated feature testing becomes harder, and reciprocal navigation can eventually create a circular dependency.
The healthier shape is closer to:
:feature:checkout:impl ---> :feature:payment:api
:feature:payment:impl ---> :feature:checkout:api (only if needed)
:app ---> both feature implementations
The api surface contains only what another feature needs in order to request navigation. The destination UI and its dependencies stay inside impl. The app module becomes the composition root that knows about concrete implementations.
This API/implementation split now appears directly in Android’s Navigation 3 modularization guidance: navigation keys belong in a feature’s API module, while its entries and navigable content belong in the implementation module.
Prefer typed navigation contracts over destination implementation references
A navigation boundary should communicate intent, not leak the destination’s implementation details.
For example, checkout might depend on a small contract:
interface PaymentNavigator {
fun openPayment(orderId: String)
}
Checkout can test that openPayment() is requested without knowing whether payment is rendered by an Activity, Fragment, Compose destination, or a future replacement.
For a route-based stack, the public API can instead expose a typed navigation key:
@Serializable
data class PaymentRoute(val orderId: String)
The payment implementation registers the content associated with that key, while the app assembles all entries. This is preferable to passing arbitrary route strings between feature modules because the compiler can validate the contract and refactoring becomes safer.
If you are still on the Views/Fragments Navigation Component, Safe Args serves a similar purpose for supported destinations by generating type-safe argument classes. The exact mechanism changes by navigation stack; the architectural goal does not.
Where Android Intent still belongs
Intent is not obsolete in a modular application. It remains the Android platform mechanism for launching components and handling external entry points. The mistake is making raw Intent construction the public architecture of every feature.
An explicit Activity launch can be hidden behind the same contract:
class PaymentNavigatorImpl(
private val context: Context,
) : PaymentNavigator {
override fun openPayment(orderId: String) {
context.startActivity(
Intent(context, PaymentActivity::class.java)
.putExtra("order_id", orderId)
)
}
}
Now the caller depends on PaymentNavigator, while only the implementation knows about PaymentActivity and Android’s Intent details.
Using Intent.setClassName() or string-based implicit routing can avoid a compile-time module dependency, but that trades coupling for runtime risk. A typo or removed class is no longer caught by the compiler. Use that trade-off deliberately rather than treating strings as modularization.
Deep links are another legitimate boundary. For externally addressable destinations, each feature can own its deep-link matching rules while the app collects them. That keeps ownership local without forcing every feature to understand every destination implementation.
Let the app module compose implementations
Once navigation contracts are separated from implementation, something still has to connect them. That responsibility belongs naturally to the application or another explicit composition module.
With dependency injection, the app can bind a navigation interface to its implementation. Navigation 3 goes further by allowing feature modules to contribute entry builders through DI multibindings, so the main Activity does not need a growing list of hardcoded feature registrations.
The key rule is simple:
Feature modules may know public navigation contracts. The composition root may know implementations.
That direction prevents the navigation abstraction itself from becoming a new god module containing every screen, route, dependency, and piece of business logic.
If you’re deciding how far to split a feature, the RayLabs guide on modularizing an Android detail screen covers the broader dependency and testing trade-offs beyond navigation alone.
A practical module layout
You do not need dozens of Gradle modules on day one. A useful target for a sufficiently large feature is:
:feature:checkout:api
CheckoutRoute.kt
:feature:checkout:impl
CheckoutScreen.kt
CheckoutEntry.kt
:feature:payment:api
PaymentRoute.kt
:feature:payment:impl
PaymentScreen.kt
PaymentEntry.kt
:app
navigation composition
DI bindings / entry collection
For a smaller codebase, :feature:payment plus a lightweight shared :core:navigation contract can be enough. The API/impl split earns its cost when independent compilation, ownership, reuse, or dependency isolation matters. Creating two modules for every tiny screen can make Gradle configuration and project navigation worse without delivering meaningful isolation.
That is the trade-off often missing from modularization advice: the cleanest dependency graph is not automatically the simplest system to maintain.
How to migrate without rewriting navigation at once
A safe migration is incremental.
First, pick one cross-feature transition that currently creates an undesirable dependency. Introduce a typed contract for that transition and move concrete destination knowledge behind an implementation. Bind it from the app layer, then remove the original feature-to-feature dependency.
Only after the boundary works should you repeat the pattern elsewhere.
A useful migration sequence is:
- map current feature-to-feature Gradle dependencies;
- identify navigation-only dependencies;
- extract the smallest public route or navigator contract;
- move destination construction into the owning implementation module;
- wire implementations from the app/composition layer;
- remove the old direct dependency;
- run unit, navigation, deep-link, and process-restoration tests.
If the codebase is also moving between Activity- and Fragment-owned navigation, migrating navigation graphs from Activities to Fragments is a useful companion because lifecycle and back-stack ownership become part of the migration rather than a separate concern.
Test the boundary, not only the happy-path screen transition
A modular navigation architecture should make tests narrower.
At the feature level, verify that user actions produce the correct typed route or navigator call. You do not need the destination feature present for that test.
At the composition level, verify that every public route has an installed implementation. This catches the opposite failure: an architecture that compiles because contracts are clean but crashes because the app forgot to register a destination.
For navigation carrying arguments, test malformed or missing external inputs separately from internal typed calls. For deep links, verify matching and back-stack construction. For Activity-based destinations, verify required extras and task/back-stack behavior.
Finally, test process recreation. A route object that works while everything remains in memory is not sufficient if it cannot be restored after Android recreates the process.
When not to introduce a navigation abstraction
Do not add a navigator interface simply because “clean architecture” says every call needs an abstraction.
If two screens live in the same small feature module, are owned together, and are unlikely to require independent compilation, direct typed navigation may be clearer. The abstraction becomes valuable at a boundary: separate features, separate ownership, reusable modules, dynamic features, or dependencies that are already causing build and testing friction.
Likewise, do not hide every Android API behind a wrapper. Keep Intent, deep-link, and navigation framework details where they genuinely belong; prevent them from leaking across boundaries that should remain independent.
The architecture rule worth keeping
For Android multi-module navigation, optimize the dependency direction before optimizing the navigation syntax.
A feature should publish a small typed contract describing how it can be reached. Its implementation should own the screen and framework-specific wiring. The app should compose implementations. With that structure, switching from an Activity to Compose, adopting Navigation 3, or changing DI becomes an implementation migration instead of a dependency-graph rewrite.
Start with one problematic cross-feature edge. If extracting its navigation contract removes a real dependency and makes the caller testable in isolation, the boundary is paying for itself. If it only adds another interface and Gradle module, keep the simpler design.
Continue Exploring
You Might Also Like

Mastering List to String Conversion in Mobile Development
An in-depth guide on handling list to string conversion, managing Android lifecycles, and avoiding memory leaks during state transformation.

Android Date and Time: Model Instants, Local Dates, and Time Zones Correctly
Learn how to model and format date and time in Android with Kotlin by separating absolute instants, local calendar values, time zones, localization, and testable presentation logic.

FCM Delivery Monitoring: Know What Sent, Delivered, and Opened Actually Mean
A practical guide to Firebase Cloud Messaging observability that separates send acceptance, aggregated delivery, app processing, and user interaction instead of treating one success response as proof of delivery.