Navigation3

Introduction: A clean, scalable example of multi-module Jetpack Compose navigation using Navigation 3 and type-safe routes.
More: Author   ReportBugs   
Tags:

License Kotlin Compose

A clean, scalable approach to Jetpack Compose navigation in multi-module Android apps using Navigation 3 (androidx.navigation3).

Key Features

  • Type-safe navigation with Kotlinx Serialization
  • Module independence - features depend only on shared navigation module
  • Navigation results - pass data back when navigating up (like Activity result API)
  • Bottom tab navigation - tab roots preserved in a single back stack

Architecture

app/           # Coordinates navigation and UI
navigation/    # Shared abstractions (Navigator, AppScreen, NavigationResults)
home/          # Feature module
search/        # Feature module
profile/       # Feature module
details/       # Feature module

Core Concepts:

  • AppComposeNavigator - Navigation interface with navigate(), navigateUp(), popUpTo(), navigateBackWithResult()
  • AppScreen - Sealed interface for all destinations (uses @Serializable for type-safe arguments)
  • NavigationResults - Type-safe result passing between screens

Quick Examples

Type-Safe Navigation

// Define destination
@Serializable
data class Details(
    val placeholderId: String,
    val initialIsFavorite: Boolean,
    val initialIsInCart: Boolean,
) : AppScreen

// Navigate
navigator.navigate(
    Details(
        placeholderId = "item-123",
        initialIsFavorite = true,
        initialIsInCart = false
    )
)

Send result:

navigator.navigateBackWithResult(
    key = Details.RESULT_KEY,
    result = Details.Result(
        placeholderId = placeholderId,
        isFavorite = isFavorite,
        isInCart = isInCart,
    )
)

Receive result:

LaunchedEffect(results) {
    results.resultFlow<Details.Result>(Details.RESULT_KEY)
        .collect { result ->
            viewModel.onItemStateChanged(result)
            results.clear(Details.RESULT_KEY) // Must clear to avoid re-delivery
        }
}

Best Practice: Keep result keys and types inside the destination definition (e.g., Details.RESULT_KEY and Details.Result) to create clear contracts.

Entry Provider

fun appEntryProvider(...) = entryProvider {
    entry<HomeTab> { HomeTabRoute(navigator, results) }
    entry<Details> { key -> DetailsRoute(navigator, key.placeholderId, ...) }
}

Why This Approach?

  • Type safety - Compile-time checking, refactoring-friendly
  • Module independence - Feature modules only depend on navigation abstractions
  • Clear contracts - Destinations own their keys and result types
  • Testable - Navigator is an interface, easy to mock
  • Scalable - Navigation logic centralized, easy to reason about

⚠️ Note: Results use Flow with replay semantics - must call results.clear(key) after consumption to avoid re-delivery.

Project Structure

app module
  ├─→ navigation (shared contracts)
  ├─→ home
  ├─→ search
  ├─→ profile
  └─→ details

Feature modules → navigation only (for AppScreen, Navigator, NavigationResults)

Feature modules never depend on each other.

License

Copyright 2026 Kyriakos Georgiopoulos

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Resources


Author: Kyriakos Georgiopoulos

⭐ Star this repo if you find it helpful!

Apps
About Me
GitHub: Trinea
Facebook: Dev Tools