Join our FREE personalized newsletter for news, trends, and insights that matter to everyone in America

Newsletter
New

Building A Scalable Navigation System For A 30+ Module Super App

Card image cap

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 LaunchedEffect to 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.