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
🧭 Navigation 3 – Navigation as State
Define Screens
@Immutable
sealed interface Screen {
object Home : Screen
data class Detail(val id: String) : Screen
}
BackStack as State
val backStack = rememberSaveable {
mutableStateListOf<Screen>(Screen.Home)
}
Navigate →
add(Screen)Back →
removeLast()
NoNavController.
NoNavGraph.
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
)
}
}
✔ 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
}
🧠 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) }
}
}
❌ 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
)
}
📌 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))
}
)
}
}
}
✔ 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()
)
}
}
Feature Module
val homeModule = module {
viewModel { HomeViewModel(get()) }
}
📌 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)
}
}
📌 Configure everything in one place.
ApiService
class ApiService(
private val client: HttpClient
) {
suspend fun getItems(): List<String> =
client.get("/items").body()
}
🗄 Local Storage – Room
Entity
@Entity(tableName = "items")
data class ItemEntity(
@PrimaryKey val id: String
)
DAO
@Dao
interface ItemDao {
@Query("SELECT * FROM items")
suspend fun getAll(): List<ItemEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<ItemEntity>)
}
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()
}
}
🔁 Repository (Single Source of Truth)
interface ItemRepository {
suspend fun getItems(): List<String>
}
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
}
}
📌 ViewModel never knows:
- Remote or local source
- Caching logic
- Mapping rules
⚡ Performance Guidelines
DO
- Use @Immutable for state & screen
- Use derivedStateOf for UI-only logic
- Key every LazyColumn item
- Collect Flow only in Route
DON'T
- Create objects in Composable body
- Collect Flow inside list items
- Put ViewModel in Screen
- 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()
)
}
🏁 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)