Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

Android Navigation and Architecture Patterns

October 5, 2025 · 8 min read

Android, Kotlin, Navigation, Hilt, Dependency Injection, Interview Prep

Part 3 of the Android Deep Dive series

Navigation and dependency injection form the architectural backbone of modern Android apps. In 2025-2026 interviews, expect questions about Navigation Compose patterns, Hilt scoping, and the repository pattern. With type-safe navigation APIs now stable in Navigation 2.8+, interviewers are updating their expectations.

2025-2026 Trend

Single-activity architecture is now standard. Interviewers expect you to explain why you'd use it and how Navigation Compose handles deep links, results, and nested graphs.

Navigation Compose Setup

Defining Routes

// Define routes as sealed class for type safety
sealed class Screen(val route: String) {
    data object Dashboard : Screen("dashboard")
    data object MealLog : Screen("meal_log")
    data object Search : Screen("search")
    data object Settings : Screen("settings")

    // Route with required argument
    data object MealDetail : Screen("meal/{mealId}") {
        fun createRoute(mealId: String) = "meal/$mealId"
    }

    // Route with optional argument
    data object FoodDetail : Screen("food/{foodId}?servingSize={servingSize}") {
        fun createRoute(foodId: String, servingSize: Float? = null): String {
            return if (servingSize != null) {
                "food/$foodId?servingSize=$servingSize"
            } else {
                "food/$foodId"
            }
        }
    }
}

Basic Navigation

@Composable
fun NutritionApp() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Screen.Dashboard.route
    ) {
        composable(Screen.Dashboard.route) {
            DashboardScreen(
                onMealClick = { mealId ->
                    navController.navigate(Screen.MealDetail.createRoute(mealId))
                },
                onAddMealClick = {
                    navController.navigate(Screen.MealLog.route)
                }
            )
        }

        composable(Screen.MealLog.route) {
            MealLogScreen(
                onSearchFood = {
                    navController.navigate(Screen.Search.route)
                },
                onComplete = {
                    navController.popBackStack()
                }
            )
        }

        composable(
            route = Screen.MealDetail.route,
            arguments = listOf(
                navArgument("mealId") { type = NavType.StringType }
            )
        ) { backStackEntry ->
            val mealId = backStackEntry.arguments?.getString("mealId")
                ?: return@composable
            MealDetailScreen(
                mealId = mealId,
                onBack = { navController.popBackStack() }
            )
        }

        // Optional arguments
        composable(
            route = Screen.FoodDetail.route,
            arguments = listOf(
                navArgument("foodId") { type = NavType.StringType },
                navArgument("servingSize") {
                    type = NavType.FloatType
                    defaultValue = 1f
                }
            )
        ) { backStackEntry ->
            val foodId = backStackEntry.arguments?.getString("foodId")!!
            val servingSize = backStackEntry.arguments?.getFloat("servingSize") ?: 1f
            FoodDetailScreen(foodId = foodId, initialServingSize = servingSize)
        }
    }
}

Bottom Navigation

@Composable
fun MainScreen() {
    val navController = rememberNavController()

    Scaffold(
        bottomBar = {
            NavigationBar {
                val navBackStackEntry by navController.currentBackStackEntryAsState()
                val currentDestination = navBackStackEntry?.destination

                BottomNavItem.entries.forEach { item ->
                    NavigationBarItem(
                        icon = { Icon(item.icon, contentDescription = item.label) },
                        label = { Text(item.label) },
                        selected = currentDestination?.hierarchy?.any {
                            it.route == item.route
                        } == true,
                        onClick = {
                            navController.navigate(item.route) {
                                // Pop up to start to avoid building up stack
                                popUpTo(navController.graph.findStartDestination().id) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        }
                    )
                }
            }
        }
    ) { paddingValues ->
        NavHost(
            navController = navController,
            startDestination = BottomNavItem.Dashboard.route,
            modifier = Modifier.padding(paddingValues)
        ) {
            // ... destinations
        }
    }
}

enum class BottomNavItem(
    val route: String,
    val icon: ImageVector,
    val label: String
) {
    Dashboard("dashboard", Icons.Default.Home, "Home"),
    Meals("meals", Icons.Default.Restaurant, "Meals"),
    Progress("progress", Icons.Default.TrendingUp, "Progress"),
    Profile("profile", Icons.Default.Person, "Profile")
}

Deep Linking

// Navigation setup with deep links
composable(
    route = Screen.MealDetail.route,
    arguments = listOf(navArgument("mealId") { type = NavType.StringType }),
    deepLinks = listOf(
        navDeepLink {
            uriPattern = "myapp://meal/{mealId}"
        },
        navDeepLink {
            uriPattern = "https://myapp.com/meal/{mealId}"
        }
    )
) { backStackEntry ->
    val mealId = backStackEntry.arguments?.getString("mealId")!!
    MealDetailScreen(mealId = mealId)
}

Interview Tip

Remember to declare deep link intent filters in AndroidManifest.xml. Interviewers often ask about the full setup, not just the Navigation code.

Hilt Dependency Injection

Setup

// Application class
@HiltAndroidApp
class NutritionApplication : Application()

// MainActivity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            NutritionTheme {
                NutritionApp()
            }
        }
    }
}

Modules

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): NutritionDatabase {
        return Room.databaseBuilder(
            context,
            NutritionDatabase::class.java,
            "nutrition.db"
        ).build()
    }

    @Provides
    @Singleton
    fun provideMealDao(database: NutritionDatabase): MealDao {
        return database.mealDao()
    }

    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BuildConfig.API_BASE_URL)
            .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
            .build()
    }
}

// Bind interfaces to implementations
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindMealRepository(impl: MealRepositoryImpl): MealRepository
}

Hilt ViewModels

@HiltViewModel
class DashboardViewModel @Inject constructor(
    private val mealRepository: MealRepository,
    private val userPreferences: UserPreferencesRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    // SavedStateHandle survives process death
    private val selectedDate = savedStateHandle.getStateFlow(
        "selectedDate",
        LocalDate.now().toString()
    )

    val uiState: StateFlow<DashboardUiState> = combine(
        selectedDate,
        mealRepository.observeMeals(),
        userPreferences.nutritionGoals
    ) { dateStr, meals, goals ->
        val date = LocalDate.parse(dateStr)
        val todaysMeals = meals.filter { it.loggedAt.toLocalDate() == date }

        DashboardUiState(
            date = date,
            meals = todaysMeals,
            goals = goals,
            totals = calculateTotals(todaysMeals)
        )
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = DashboardUiState()
    )

    fun setDate(date: LocalDate) {
        savedStateHandle["selectedDate"] = date.toString()
    }
}

// Using in Composable
@Composable
fun DashboardScreen(
    viewModel: DashboardViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    DashboardContent(
        state = uiState,
        onDateChange = viewModel::setDate
    )
}

Repository Pattern

interface MealRepository {
    fun observeMeals(): Flow<List<Meal>>
    suspend fun getMeal(id: String): Meal?
    suspend fun saveMeal(meal: Meal)
    suspend fun deleteMeal(meal: Meal)
    suspend fun syncWithServer()
}

class MealRepositoryImpl @Inject constructor(
    private val mealDao: MealDao,
    private val mealApi: MealApi,
    @IoDispatcher private val dispatcher: CoroutineDispatcher
) : MealRepository {

    override fun observeMeals(): Flow<List<Meal>> {
        return mealDao.observeAll()
            .map { entities -> entities.map { it.toDomain() } }
            .flowOn(dispatcher)
    }

    override suspend fun getMeal(id: String): Meal? {
        return withContext(dispatcher) {
            mealDao.getById(id)?.toDomain()
        }
    }

    override suspend fun saveMeal(meal: Meal) {
        withContext(dispatcher) {
            mealDao.insert(meal.toEntity())
            try {
                mealApi.createMeal(meal.toDto())
            } catch (e: Exception) {
                // Queue for later sync
            }
        }
    }
}

Common Mistake

Don't inject ViewModel directly into composables or other classes. Always use hiltViewModel() in composables or let Hilt create ViewModels via @HiltViewModel.

Custom Dispatchers

@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {

    @Provides
    @IoDispatcher
    fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO

    @Provides
    @DefaultDispatcher
    fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
}

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher

Interview Questions

Q: How does Hilt differ from Dagger?

Hilt is built on top of Dagger with Android-specific features:

AspectDaggerHilt
SetupManual componentsPredefined components
Android lifecycleManualBuilt-in support
ViewModelManual factory@HiltViewModel
BoilerplateMoreLess
// Dagger - Manual component setup
@Component(modules = [AppModule::class])
interface AppComponent {
    fun inject(activity: MainActivity)
}

// Hilt - Automatic
@HiltAndroidApp
class MyApplication : Application()

@AndroidEntryPoint
class MainActivity : ComponentActivity()

Q: What's the difference between @Inject constructor and @Provides?

  • Constructor injection (@Inject): For classes you own
  • @Provides: For third-party classes or when you need custom creation logic
// @Inject - Hilt creates instances automatically
class MealRepositoryImpl @Inject constructor(
    private val dao: MealDao
) : MealRepository

// @Provides - For classes you don't own
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BASE_URL)
            .build()
    }
}

Q: What are Hilt component scopes and when to use each?

ScopeComponentLifecycle
@SingletonSingletonComponentApplication
@ActivityRetainedScopedActivityRetainedComponentViewModel
@ActivityScopedActivityComponentActivity
@ViewModelScopedViewModelComponentViewModel
@FragmentScopedFragmentComponentFragment
// Singleton - app-wide, one instance
@Singleton
class UserSession @Inject constructor()

// ViewModelScoped - tied to ViewModel lifecycle
@ViewModelScoped
class MealValidator @Inject constructor()

Q: How do you handle navigation arguments in Compose?

// Define route with placeholder
composable(
    route = "meal/{mealId}",
    arguments = listOf(
        navArgument("mealId") {
            type = NavType.StringType
            nullable = false
        }
    )
) { backStackEntry ->
    val mealId = backStackEntry.arguments?.getString("mealId")!!
    MealDetailScreen(mealId)
}

// Navigate with value
navController.navigate("meal/12345")

// Optional arguments use query params
composable(
    route = "food/{id}?serving={serving}",
    arguments = listOf(
        navArgument("id") { type = NavType.StringType },
        navArgument("serving") {
            type = NavType.FloatType
            defaultValue = 1f
        }
    )
)

Common Mistakes

1. Not Saving Navigation State

// BAD - State lost on config change
@Composable
fun MainScreen() {
    var selectedTab by remember { mutableStateOf(0) }
}

// GOOD - State survives config change
@Composable
fun MainScreen() {
    var selectedTab by rememberSaveable { mutableStateOf(0) }
}

2. Creating ViewModel in Wrong Scope

// BAD - New ViewModel per recomposition
@Composable
fun Screen() {
    val viewModel = MealsViewModel() // Wrong!
}

// BAD - Wrong scope (new VM per list item)
@Composable
fun MealItem(meal: Meal) {
    val viewModel: MealsViewModel = hiltViewModel() // New VM per item!
}

// GOOD - Proper scoping
@Composable
fun MealsScreen() {
    val viewModel: MealsViewModel = hiltViewModel() // One per screen

    LazyColumn {
        items(meals) { meal ->
            MealItem(meal) // Pass data, not ViewModel
        }
    }
}

3. Forgetting to Pop Back Stack

// BAD - Builds up back stack
navController.navigate("success")

// GOOD - Clear appropriate entries
navController.navigate("success") {
    popUpTo("login") { inclusive = true }
}

4. Circular Dependencies

// BAD - A depends on B, B depends on A
class ServiceA @Inject constructor(private val b: ServiceB)
class ServiceB @Inject constructor(private val a: ServiceA) // Crash!

// GOOD - Use Provider or Lazy
class ServiceA @Inject constructor(private val b: Lazy<ServiceB>)
class ServiceB @Inject constructor(private val a: ServiceA)

Summary Table

ConceptPurposeKey Points
NavHostContainer for navigationDefines all destinations
navArgumentPass data between screensUse type-safe route builders
@HiltAndroidAppEnable HiltRequired on Application class
@AndroidEntryPointInject into Android classesActivities, Fragments, Services
@HiltViewModelInject ViewModelsWorks with hiltViewModel()
@ProvidesProvide third-party classesIn @Module classes
@BindsBind interface to implementationAbstract function in module
SavedStateHandleSurvive process deathInject in ViewModel

Next in series: Part 4 - Networking with Coroutines covers Retrofit, error handling, and authentication.

← Previous

Android Networking with Coroutines

Next →

Android State Management Deep Dive