Navigation is one of the most critical and complex aspects of mobile system design. As apps grow, tight coupling between screens ("Feature A imports Feature B") creates a monolithic spaghetti code structure that is hard to test, refactor, and modularize.
The Challenge: How do you design a navigation system that decouples feature modules, supports deep linking, handles complex flows (e.g., authentication), and maintains state across configuration changes?
- Decoupling: Module A should navigate to Module B without knowing Module B exists.
- Deep Linking: The app must be able to open any specific screen from a URL (e.g.,
app://profile/123). - Result Handling: Screen B needs to return data to Screen A (e.g., selecting a contact).
- Back Stack Management: Handling the hardware back button (Android) or swipe-to-back (iOS) correctly.
- Context/State Preservation: Restoring the navigation stack after a process death.
- Concept: Screen A directly imports and instantiates Screen B.
- Example:
Navigator.push(new DetailScreen(id))
- Example:
- The Problem: Tight Coupling. The "Feed" module must compile the "Detail" module. You cannot split them into separate build modules easily. Circular dependencies arise quickly (Profile -> Feed -> Profile), leading to a monolithic architecture that scales poorly.
A dedicated object handles the flow logic, removing it from the UI components.
- How it works:
FeedScreencallscoordinator.showDetail(id).FeedCoordinatorknows how to assemble theDetailScreenand its dependencies.FeedCoordinatorpushes it onto the navigation stack.
- Pros: Isolates navigation logic from UI logic. Excellent for unit testing navigation flows.
- Cons: Coordinators can become "God Objects" if not split properly. Still requires the Coordinator to know about the destination classes unless combined with Dependency Injection.
Every screen is a URL.
- How it works:
FeedModuleasks:Router.navigate("app://detail?id=123").Routerlooks up the registration map.Routerinstantiates the target screen and pushes it.
- Pros: Zero compile-time coupling. Modules only know the Router interface. Identical logic for internal navigation and external Deep Links.
- Cons: Loss of type safety (passing strings instead of objects).
- Concept: A centralized file (JSON/XML/DSL) defines all possible destinations and paths ("Actions").
- The Signal: This treats navigation as a State Machine. You define the states (Screens) and transitions (Actions).
- Type Safety: Modern tools generate code from this graph to ensure type safety for arguments, fixing the main downside of loose URI-based navigation.
- Modularization: Large graphs can be split into "Nested Graphs" (e.g.,
LoginGraph,CheckoutGraph), allowing different teams to own different parts of the navigation flow.
In a modularized app where FeedModule and SettingsModule are separate binaries that don't know about each other, how do they navigate?
Option A: Interface Injection (The Clean Architecture Way)
- Define an interface in a shared core module:
interface FeedNavigator { fun goToSettings() }. FeedModuledepends onFeedNavigator.- The main
AppModule(which composes everything) implementsFeedNavigatorand injects it intoFeedModule.
Option B: Implicit Deep Links (The Loose Coupling Way)
FeedModuleasks the Router to navigate to a generic URI:app://settings.- The Router resolves this string to the
SettingsScreenat runtime. - Trade-off: Loss of compile-time safety (typos in strings cause runtime failures).
A common interview question: "If a user opens a Deep Link to app://profile/settings, what happens when they press Back?"
- The Wrong Answer: "The app closes." (This feels broken to the user).
- The Right Answer (Synthetic Back Stack): The navigation system must recognize that
Settingsbelongs to a hierarchy. It should synthesize the parent screens (Home->Profile->Settings) onto the back stack so the user navigates "Up" the hierarchy, preserving the expected user flow.
- The "God" Router: Handling all navigation logic in a single file or class. Split logic into sub-routers or coordinators per feature.
- Circular Dependencies: Feature A importing Feature B directly, creating a monolithic build graph that prevents parallel compilation.
- Ignoring Process Death: Failing to save and restore the navigation stack state. If the OS kills the app to save memory, the user must return to the exact same screen hierarchy, not the home screen.
- Stringly-Typed Navigation: Using raw strings (
"app://detail") everywhere without a centralized constant file or builder pattern. This leads to runtime crashes from simple typos.
- Decoupling Strategy: "I will use a Router pattern (or Coordinators) to ensure Feature A doesn't depend on Feature B."
- Deep Linking: "I will treat internal navigation and external deep links uniformly using a centralized URL resolver."
- Type Safety: "I will use code generation (like SafeArgs or a custom struct generator) to ensure parameter type safety between decoupled modules."
- State Restoration: "I will ensure the Router can serialize the back stack to handle process death."
- Square: The blind leading the blind (Coordinators)
- Uber: RIBs Architecture (Router-Interactor-Builder)
- Android: Navigation Component Guide
- Airbnb: Deep Link Dispatch