DEV Community

Riazul Karim Ivan
Riazul Karim Ivan

Posted on

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)
Enter fullscreen mode Exit fullscreen mode

We intentionally separate:

  • Entry resolution
  • Security validation
  • Feature navigation
  • UI containers

Scalable Navigation System

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()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Then split by concern:

SecurityRouter
DashboardRouter
FeatureRouter
DialogRouter
ExternalRouter
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Dispatcher calls:

DeepLink → SecurityRouter → FinalDestination
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

Each module exposes:

fun NavGraphBuilder.cryptoGraph(navController: NavController)
Enter fullscreen mode Exit fullscreen mode

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) }
}
Enter fullscreen mode Exit fullscreen mode

Each tab owns:

Independent NavHost
Independent back stack
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

Example:

fun onCryptoClick() {
    featureRouter.navigate(Destination.CryptoBuy(asset = "BTC"))
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Dispatcher logic:

fun resolve(intent: Intent) {
    val parsed = deepLinkParser.parse(intent)

    val secured = securityRouter.evaluate(parsed)

    _destination.value = secured
}
Enter fullscreen mode Exit fullscreen mode

If crypto feature disabled:

Destination.FeatureUnavailable
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Mapping:

when(type) {
    CRYPTO_PRICE_ALERT -> Destination.CryptoBuy(asset)
    TRANSFER_RECEIVED -> Destination.TransferDetail(id)
}
Enter fullscreen mode Exit fullscreen mode

Single resolution system for both deep links and notifications.


10. Handling Dialog & BottomSheet Navigation

Use Compose Navigation dialog destinations:

dialog(
    route = "upgrade_dialog"
) {
    UpgradeDialog()
}
Enter fullscreen mode Exit fullscreen mode

For full screen bottom sheet:

Use:

ModalBottomSheet
Enter fullscreen mode Exit fullscreen mode

Inside same NavHost.

Avoid new Activity.


11. PIN & Face Scan Flow

Security overlay flow:

If destination requires validation:

Destination.PinValidation(next = Transfer)
Enter fullscreen mode Exit fullscreen mode

In NavGraph:

composable("pin_validation") {
    PinScreen(
        onSuccess = {
            navController.navigate(next.route())
        }
    )
}
Enter fullscreen mode Exit fullscreen mode

Face scan same pattern.


12. Handling Inactive State (Session Timeout)

Use:

ProcessLifecycleOwner
Enter fullscreen mode Exit fullscreen mode

Track background timestamp.

On resume:

if (timeout) {
   navController.navigate("pin_validation")
}
Enter fullscreen mode Exit fullscreen mode

Important:

Do NOT recreate MainActivity.

Push overlay screen.


13. Dynamic Permissions & Server-Driven Menu

API returns enabled modules:

["HOME","CARDS","CRYPTO"]
Enter fullscreen mode Exit fullscreen mode

Build tabs dynamically:

val tabs = apiModules.map { it.toBottomTab() }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

We use:

DeepLink → Dispatcher (ViewModel + API) → Router → Destination
Enter fullscreen mode Exit fullscreen mode

This:

  • Eliminates navigation race conditions
  • Avoids feature coupling
  • Handles async validation
  • Makes super app manageable

Another use-case showing for an eduction app:

Eduction app navigation

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.

Top comments (0)