DEV Community

Cover image for Building a Modern Android UI Stack with Jetpack Compose (Senior Guide
ViO Tech
ViO Tech

Posted on

Building a Modern Android UI Stack with Jetpack Compose (Senior Guide

A practical, scalable, and testable UI architecture using

Jetpack Compose + Navigation 3 + MVVM + MVI + Koin + Ktor + Room


🎯 Goal

This article presents a production-ready UI stack for modern Android apps, focusing on:

  • Predictable state management
  • Navigation as state (Navigation 3)
  • Performance & recomposition safety
  • Scalability for large teams
  • High testability
  • Fast feature development

This is not a beginner tutorial — it’s a Senior-level playbook.


🧱 Tech Stack (Final Choice)

Layer Technology
UI Jetpack Compose
Navigation Navigation 3 (State-driven)
Architecture MVVM + MVI hybrid
DI Koin
Networking Ktor
Local Storage Room
Async Coroutines + Flow

🧠 Core Philosophy

1️⃣ UI is a pure function of State

2️⃣ Navigation is State

3️⃣ ViewModel never knows UI or Navigation

4️⃣ Side-effects are explicit

5️⃣ Everything must be testable without Android runtime


📦 Project Structure

feature-home/
├── HomeContract.kt
├── HomeViewModel.kt
├── HomeRoute.kt
├── HomeScreen.kt
Enter fullscreen mode Exit fullscreen mode

🧭 Navigation 3 – Navigation as State

Define Screens

@Immutable
sealed interface Screen {
    object Home : Screen
    data class Detail(val id: String) : Screen
}
Enter fullscreen mode Exit fullscreen mode

BackStack as State

val backStack = rememberSaveable {
    mutableStateListOf<Screen>(Screen.Home)
}
Enter fullscreen mode Exit fullscreen mode
  • Navigate → add(Screen)

  • Back → removeLast()
    No NavController.
    No NavGraph.
    Just state.


🧭 AppNavigationHost

@Composable
fun AppNavigationHost(
    backStack: List<Screen>,
    onNavigate: (Screen) -> Unit,
    onBack: () -> Unit
) {
    when (val screen = backStack.last()) {
        Screen.Home -> HomeRoute(
            onNavigateDetail = { onNavigate(Screen.Detail(it)) }
        )

        is Screen.Detail -> DetailRoute(
            id = screen.id,
            onBack = onBack
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

✔ Explicit
✔ Testable
✔ No magic


🧩 MVVM + MVI Hybrid
Contract

data class State(
    val items: List<String> = emptyList(),
    val loading: Boolean = false
)

sealed interface Event {
    object Load : Event
    data class ItemClicked(val id: String) : Event
}

sealed interface Effect {
    data class NavigateDetail(val id: String) : Effect
}

Enter fullscreen mode Exit fullscreen mode

🧠 ViewModel (Pure Logic)

class HomeViewModel(
    private val repo: ItemRepository
) : ViewModel() {

    private val _state = MutableStateFlow(State())
    val state: StateFlow<State> = _state

    private val _effect = Channel<Effect>()
    val effect = _effect.receiveAsFlow()

    fun onEvent(event: Event) {
        when (event) {
            Event.Load -> loadItems()
            is Event.ItemClicked ->
                sendEffect(Effect.NavigateDetail(event.id))
        }
    }

    private fun loadItems() {
        viewModelScope.launch {
            _state.update { it.copy(loading = true) }
            val items = repo.getItems()
            _state.update { it.copy(items = items, loading = false) }
        }
    }

    private fun sendEffect(effect: Effect) {
        viewModelScope.launch { _effect.send(effect) }
    }
}

Enter fullscreen mode Exit fullscreen mode

❌ No Compose
❌ No Navigation
✅ 100% testable


🧩 Route – Glue Layer

@Composable
fun HomeRoute(
    onNavigateDetail: (String) -> Unit,
    vm: HomeViewModel = koinViewModel()
) {
    val state by vm.state.collectAsStateWithLifecycle()

    LaunchedEffect(Unit) {
        vm.effect.collect { effect ->
            when (effect) {
                is Effect.NavigateDetail ->
                    onNavigateDetail(effect.id)
            }
        }
    }

    HomeScreen(
        state = state,
        onEvent = vm::onEvent
    )
}
Enter fullscreen mode Exit fullscreen mode

📌 Only place where:

Flow is collected

Navigation happens


🎨 Screen – Pure UI

@Composable
fun HomeScreen(
    state: State,
    onEvent: (Event) -> Unit
) {
    LazyColumn {
        items(
            items = state.items,
            key = { it }
        ) { item ->
            Text(
                text = item,
                modifier = Modifier.clickable {
                    onEvent(Event.ItemClicked(item))
                }
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

✔ Stateless
✔ Previewable
✔ Reusable


🧬 Dependency Injection – Koin
Why Koin?

  • No code generation
  • No annotation processing
  • Fast build time
  • Compose-friendly
  • Easy to reason about in large teams

DI should not be the hardest part of your architecture.


*App Module
*

val appModule = module {

    // Network
    single { provideHttpClient() }
    single { ApiService(get()) }

    // Database
    single { AppDatabase.create(androidContext()) }
    single { get<AppDatabase>().itemDao() }

    // Repository
    single<ItemRepository> {
        ItemRepositoryImpl(
            api = get(),
            dao = get()
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Feature Module

val homeModule = module {
    viewModel { HomeViewModel(get()) }
}
Enter fullscreen mode Exit fullscreen mode

📌 ViewModels depend on interfaces only


🌐 Networking – Ktor

HttpClient Setup

fun provideHttpClient(): HttpClient =
    HttpClient(Android) {

        install(ContentNegotiation) {
            json(Json {
                ignoreUnknownKeys = true
                explicitNulls = false
            })
        }

        install(Logging) {
            level = LogLevel.BODY
        }

        install(HttpTimeout) {
            requestTimeoutMillis = 15_000
            connectTimeoutMillis = 15_000
        }

        defaultRequest {
            contentType(ContentType.Application.Json)
        }
    }
Enter fullscreen mode Exit fullscreen mode

📌 Configure everything in one place.

ApiService

class ApiService(
    private val client: HttpClient
) {
    suspend fun getItems(): List<String> =
        client.get("/items").body()
}
Enter fullscreen mode Exit fullscreen mode

🗄 Local Storage – Room

Entity

@Entity(tableName = "items")
data class ItemEntity(
    @PrimaryKey val id: String
)
Enter fullscreen mode Exit fullscreen mode

DAO

@Dao
interface ItemDao {

    @Query("SELECT * FROM items")
    suspend fun getAll(): List<ItemEntity>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(items: List<ItemEntity>)
}
Enter fullscreen mode Exit fullscreen mode

Database

@Database(
    entities = [ItemEntity::class],
    version = 1
)
abstract class AppDatabase : RoomDatabase() {

    abstract fun itemDao(): ItemDao

    companion object {
        fun create(context: Context): AppDatabase =
            Room.databaseBuilder(
                context,
                AppDatabase::class.java,
                "app.db"
            ).build()
    }
}
Enter fullscreen mode Exit fullscreen mode

🔁 Repository (Single Source of Truth)

interface ItemRepository {
    suspend fun getItems(): List<String>
}
Enter fullscreen mode Exit fullscreen mode
class ItemRepositoryImpl(
    private val api: ApiService,
    private val dao: ItemDao
) : ItemRepository {

    override suspend fun getItems(): List<String> {
        val remote = api.getItems()
        dao.insertAll(remote.map { ItemEntity(it) })
        return remote
    }
}
Enter fullscreen mode Exit fullscreen mode

📌 ViewModel never knows:

  • Remote or local source
  • Caching logic
  • Mapping rules

⚡ Performance Guidelines

DO

  1. Use @Immutable for state & screen
  2. Use derivedStateOf for UI-only logic
  3. Key every LazyColumn item
  4. Collect Flow only in Route

DON'T

  1. Create objects in Composable body
  2. Collect Flow inside list items
  3. Put ViewModel in Screen
  4. Navigate from ViewModel

🧪 Testing Example

@Test
fun click_item_emit_navigation_effect() = runTest {
    val vm = HomeViewModel(fakeRepo)
    vm.onEvent(Event.ItemClicked("1"))

    assertEquals(
        Effect.NavigateDetail("1"),
        vm.effect.first()
    )
}
Enter fullscreen mode Exit fullscreen mode

🏁 Final Thoughts

This stack provides:

✅ Predictable state
✅ Navigation without magic
✅ Excellent testability
✅ High performance
✅ Scales well for large team

Jetpack Compose + Navigation 3 + MVI is the future-ready Android UI architecture.

Top comments (0)