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:
| Aspect | Dagger | Hilt |
|---|---|---|
| Setup | Manual components | Predefined components |
| Android lifecycle | Manual | Built-in support |
| ViewModel | Manual factory | @HiltViewModel |
| Boilerplate | More | Less |
// 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?
| Scope | Component | Lifecycle |
|---|---|---|
@Singleton | SingletonComponent | Application |
@ActivityRetainedScoped | ActivityRetainedComponent | ViewModel |
@ActivityScoped | ActivityComponent | Activity |
@ViewModelScoped | ViewModelComponent | ViewModel |
@FragmentScoped | FragmentComponent | Fragment |
// 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
| Concept | Purpose | Key Points |
|---|---|---|
NavHost | Container for navigation | Defines all destinations |
navArgument | Pass data between screens | Use type-safe route builders |
@HiltAndroidApp | Enable Hilt | Required on Application class |
@AndroidEntryPoint | Inject into Android classes | Activities, Fragments, Services |
@HiltViewModel | Inject ViewModels | Works with hiltViewModel() |
@Provides | Provide third-party classes | In @Module classes |
@Binds | Bind interface to implementation | Abstract function in module |
SavedStateHandle | Survive process death | Inject in ViewModel |
Next in series: Part 4 - Networking with Coroutines covers Retrofit, error handling, and authentication.