Building A Scalable Navigation System For A 30+ Module Super App
I recently interviewed for a Senior Android role in Europe, where the core discussion revolved around large-scale navigation systems.
That conversation reminded me of something: designing navigation for a 30+ module multi-wallet super app is a completely different game compared to standard Android apps.
It’s not just about NavController or deep links. It’s about:
- Modular isolation
- Feature ownership
- Cross-module communication
- State-driven routing
- Scalability without chaos
So let’s break down how to architect a robust, scalable in-app navigation system for a super app (think Revolut-style), while considering real-world constraints and growth challenges.
1. Core Architecture Overview
We use a layered navigation system:
DeepLinkActivity / NotificationReceiver
↓
NavigationDispatcherActivity (Invisible)
↓
Security State Machine
↓
MainActivity (Multi-tab NavHost)
↓
Feature NavGraphs (Per module)
We intentionally separate:
- Entry resolution
- Security validation
- Feature navigation
- UI containers
2. The Navigation Layers
Layer 1: Entry Layer
Handles:
- Deep links
- Push notifications
- External SDK returns
Single responsibility:
→ Parse input
→ Send to Dispatcher
Never navigate directly.
Layer 2: Dispatcher Layer (Invisible Activity Pattern)
This is the brain.
class NavigationDispatcherActivity : ComponentActivity() {
private val viewModel: DispatcherViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.resolve(intent)
lifecycleScope.launchWhenStarted {
viewModel.destination.collect { destination ->
startActivity(MainActivity.newIntent(this@NavigationDispatcherActivity, destination))
finish()
}
}
}
}
Why this is powerful:
- Can perform API calls
- Can validate security
- Can transform routes
- Can block invalid flows
- No UI flicker
3. Router Abstraction (Multiple Routers Strategy)
In a 30+ module app, one router is not enough.
We define:
interface AppRouter {
fun navigate(destination: Destination)
}
Then split by concern:
SecurityRouter
DashboardRouter
FeatureRouter
DialogRouter
ExternalRouter
Each router handles a specific layer.
Example: SecurityRouter
class SecurityRouter @Inject constructor(
private val sessionManager: SessionManager
) {
fun evaluate(destination: Destination): Destination {
return when {
!sessionManager.isLoggedIn() -> Destination.Login
sessionManager.isPinRequired() -> Destination.PinValidation(destination)
else -> destination
}
}
}
Dispatcher calls:
DeepLink → SecurityRouter → FinalDestination
4. Destination Modeling (Strongly Typed Navigation)
Never navigate with raw strings.
Use:
sealed class Destination {
object Home : Destination()
data class Transfer(
val amount: Double?,
val currency: String?
) : Destination()
data class CryptoBuy(
val asset: String
) : Destination()
object Login : Destination()
data class PinValidation(
val next: Destination
) : Destination()
}
This prevents:
- Route mismatch
- Argument errors
- Broken deep links
5. Compose Navigation Setup
Root NavHost (MainActivity)
@Composable
fun MainNavigation(startDestination: Destination) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = startDestination.route()
) {
homeGraph(navController)
paymentsGraph(navController)
cryptoGraph(navController)
cardsGraph(navController)
profileGraph(navController)
}
}
Each module exposes:
fun NavGraphBuilder.cryptoGraph(navController: NavController)
This keeps modules isolated.
6. Multi-Backstack Bottom Navigation
For a Revolut-style dashboard:
- Home
- Payments
- Crypto
- Cards
- Profile
We maintain multiple back stacks.
val navControllers = remember {
BottomTab.values().associateWith { NavHostController(context) }
}
Each tab owns:
Independent NavHost
Independent back stack
This ensures:
- Switching tabs preserves state
- Back works per tab
- Deep link can jump to specific tab
7. Injecting Routers Into Dashboard
Dashboard should not know business rules.
Inject:
class DashboardViewModel @Inject constructor(
private val featureRouter: FeatureRouter
)
Example:
fun onCryptoClick() {
featureRouter.navigate(Destination.CryptoBuy(asset = "BTC"))
}
Router decides:
- Tab switch
- Destination route
- Whether upgrade required
8. Handling Multiple Deep Link Flows
Example deep links:
revolut://transfer?amount=200
revolut://crypto/buy?asset=BTC
revolut://card/freeze?id=123
Dispatcher logic:
fun resolve(intent: Intent) {
val parsed = deepLinkParser.parse(intent)
val secured = securityRouter.evaluate(parsed)
_destination.value = secured
}
If crypto feature disabled:
Destination.FeatureUnavailable
9. Handling Notification-Based Navigation
Notifications often require:
- Open specific tab
- Open nested screen
- Show dialog
- Validate session
Instead of embedding route inside PendingIntent directly:
Use same dispatcher.
Notification click → DispatcherActivity.
Example payload:
{
"type": "CRYPTO_PRICE_ALERT",
"asset": "BTC"
}
Mapping:
when(type) {
CRYPTO_PRICE_ALERT -> Destination.CryptoBuy(asset)
TRANSFER_RECEIVED -> Destination.TransferDetail(id)
}
Single resolution system for both deep links and notifications.
10. Handling Dialog & BottomSheet Navigation
Use Compose Navigation dialog destinations:
dialog(
route = "upgrade_dialog"
) {
UpgradeDialog()
}
For full screen bottom sheet:
Use:
ModalBottomSheet
Inside same NavHost.
Avoid new Activity.
11. PIN & Face Scan Flow
Security overlay flow:
If destination requires validation:
Destination.PinValidation(next = Transfer)
In NavGraph:
composable("pin_validation") {
PinScreen(
onSuccess = {
navController.navigate(next.route())
}
)
}
Face scan same pattern.
12. Handling Inactive State (Session Timeout)
Use:
ProcessLifecycleOwner
Track background timestamp.
On resume:
if (timeout) {
navController.navigate("pin_validation")
}
Important:
Do NOT recreate MainActivity.
Push overlay screen.
13. Dynamic Permissions & Server-Driven Menu
API returns enabled modules:
["HOME","CARDS","CRYPTO"]
Build tabs dynamically:
val tabs = apiModules.map { it.toBottomTab() }
NavGraph must be modular.
Modules expose their graph via DI.
14. Rotation Handling (Compose Advantage)
Compose + ViewModel:
- Navigation state survives
- Back stack preserved
- Dispatcher ViewModel survives config change
Important:
Avoid storing navigation events as LiveData.
Use:
StateFlow + Event consumption
15. Some UI/UX Guide-line
- Use
contentDescription - Provide semantic roles
- Manage focus when switching tabs
- Use
LaunchedEffectto request focus - Ensure dialog traps focus properly
Fintech apps must be accessible.
Final System Architecture Summary
| Layer | Responsibility |
|---|---|
| Entry | Parse deep link / notification |
| Dispatcher | API + security resolution |
| Routers | Decide correct navigation |
| Main NavHost | Container |
| Feature Graphs | Isolated module flows |
| Dialog Layer | Overlays |
| Security Layer | State machine |
Why This Architecture Scales to 30+ Modules
✔ Modules don’t know about each other
✔ Navigation centralized
✔ Security enforced globally
✔ Deep link + notification unified
✔ Dynamic features supported
✔ Back stack preserved per tab
✔ Compose-native
✔ Config-safe
✔ Testable
The Invisible Dispatcher Pattern (The Cool Part)
Instead of:
DeepLink → Feature
We use:
DeepLink → Dispatcher (ViewModel + API) → Router → Destination
This:
- Eliminates navigation race conditions
- Avoids feature coupling
- Handles async validation
- Makes super app manageable
Another use-case showing for an eduction app:
Navigation is not just about moving between screens — it is the backbone of a super app’s architecture. With a properly layered navigation system, complexity becomes manageable, features remain isolated, and the entire application stays scalable and testable.
Popular Products
-
Enamel Heart Pendant Necklace$49.56$24.78 -
Digital Electronic Smart Door Lock wi...$211.78$105.89 -
Automotive CRP123X OBD2 Scanner Tool$649.56$324.78 -
Portable USB Rechargeable Hand Warmer...$61.56$30.78 -
Portable Car Jump Starter Booster - 2...$425.56$212.78