A complete walkthrough of MVVM + Compose, Clean Architecture + Hilt, and Modularization — built up step by step with diagrams and real code.
We'll use one running example: a News app with login, feed, and bookmarks. The full runnable project is on GitHub: Compose News App.
Part 1: MVVM + Jetpack Compose
1.1 The Big Picture
flowchart LR
User([User]) -->|taps button| UI[Composable UI]
UI -->|calls function| VM[ViewModel]
VM -->|asks for data| Repo[Repository]
Repo -->|returns Flow| VM
VM -->|updates StateFlow| State[(UI State)]
State -->|recomposes| UI
UI -->|displays| User
Key idea — Unidirectional Data Flow (UDF):
- State flows down (ViewModel → UI)
- Events flow up (UI → ViewModel)
1.2 The Three Pieces
flowchart TB
subgraph V[View - Composable]
direction TB
V1[Stateless UI]
V2[Observes state]
V3[Sends events up]
end
subgraph VM[ViewModel]
direction TB
VM1[Holds UI state]
VM2[Survives rotation]
VM3[Calls UseCases]
end
subgraph M[Model - Data]
direction TB
M1[Repository]
M2[Network/DB]
end
V -->|user actions| VM
VM -->|StateFlow| V
VM <--> M
1.3 Code — Login Screen with MVVM + Compose
UI State (single immutable object):
data class LoginUiState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isLoginSuccess: Boolean = false
)
ViewModel:
@HiltViewModel
class LoginViewModel @Inject constructor(
private val loginUseCase: LoginUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun onEmailChange(email: String) {
_uiState.update { it.copy(email = email) }
}
fun onPasswordChange(password: String) {
_uiState.update { it.copy(password = password) }
}
fun onLoginClick() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
loginUseCase(_uiState.value.email, _uiState.value.password)
.onSuccess {
_uiState.update { it.copy(isLoading = false, isLoginSuccess = true) }
}
.onFailure { e ->
_uiState.update { it.copy(isLoading = false, errorMessage = e.message) }
}
}
}
}
Composable (stateless, driven by state):
@Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
onLoginSuccess: () -> Unit
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.isLoginSuccess) {
if (state.isLoginSuccess) onLoginSuccess()
}
LoginContent(
state = state,
onEmailChange = viewModel::onEmailChange,
onPasswordChange = viewModel::onPasswordChange,
onLoginClick = viewModel::onLoginClick
)
}
@Composable
private fun LoginContent(
state: LoginUiState,
onEmailChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onLoginClick: () -> Unit
) {
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = state.email,
onValueChange = onEmailChange,
label = { Text("Email") }
)
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation()
)
Button(
onClick = onLoginClick,
enabled = !state.isLoading
) {
if (state.isLoading) CircularProgressIndicator() else Text("Login")
}
state.errorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
}
}
Why this is good:
LoginContentis stateless → previewable and testable- ViewModel survives configuration changes
- Single source of truth:
uiState
1.4 State vs One-Shot Events
flowchart LR
subgraph State[StateFlow - Persistent]
S1[isLoading]
S2[user list]
S3[error text]
end
subgraph Events[Channel/SharedFlow - One-shot]
E1[Show Snackbar]
E2[Navigate to Detail]
E3[Show Toast]
end
State -->|always re-emitted| UI1[UI Recomposes]
Events -->|consumed once| UI2[UI Acts Once]
class HomeViewModel : ViewModel() {
val uiState: StateFlow<HomeUiState> = ...
private val _events = Channel<HomeEvent>(Channel.BUFFERED)
val events: Flow<HomeEvent> = _events.receiveAsFlow()
}
sealed interface HomeEvent {
data class ShowSnackbar(val msg: String) : HomeEvent
data object NavigateBack : HomeEvent
}
Part 2: Clean Architecture + Hilt
2.1 The Onion — Dependency Rule
flowchart TB
subgraph Outer[Presentation Layer - Android]
UI[Composables]
VM[ViewModels]
end
subgraph Middle[Domain Layer - Pure Kotlin]
UC[Use Cases]
E[Entities]
RI[Repository Interfaces]
end
subgraph Inner[Data Layer]
RImpl[Repository Impl]
Remote[API / Retrofit]
Local[Room / DataStore]
end
UI --> VM
VM --> UC
UC --> RI
UC --> E
RImpl -.implements.-> RI
RImpl --> Remote
RImpl --> Local
The Dependency Rule: arrows point inward. Domain knows nothing about Android, Room, or Retrofit.
2.2 Data Flow Through Layers
sequenceDiagram
participant U as User
participant C as Composable
participant V as ViewModel
participant UC as GetNewsUseCase
participant R as NewsRepository
participant API as NewsApi
participant DB as NewsDao
U->>C: Pull to refresh
C->>V: onRefresh()
V->>UC: invoke()
UC->>R: getNews()
R->>API: fetchNews()
API-->>R: List of NewsDto
R->>DB: insertAll(entities)
R->>DB: observeNews()
DB-->>R: Flow of NewsEntity list
R-->>UC: Flow of News list
UC-->>V: Flow of News list
V->>V: update StateFlow
V-->>C: new UiState
C-->>U: render list
2.3 The Three Layers in Code
Domain Layer (pure Kotlin module — no Android)
data class Article(
val id: String,
val title: String,
val content: String,
val isBookmarked: Boolean
)
interface NewsRepository {
fun observeArticles(): Flow<List<Article>>
suspend fun refreshArticles(): Result<Unit>
suspend fun toggleBookmark(id: String)
}
class GetArticlesUseCase @Inject constructor(
private val repo: NewsRepository
) {
operator fun invoke(): Flow<List<Article>> = repo.observeArticles()
}
class ToggleBookmarkUseCase @Inject constructor(
private val repo: NewsRepository
) {
suspend operator fun invoke(id: String) = repo.toggleBookmark(id)
}
Data Layer
@Entity(tableName = "articles")
data class ArticleEntity(
@PrimaryKey val id: String,
val title: String,
val content: String,
val isBookmarked: Boolean
)
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles")
fun observeAll(): Flow<List<ArticleEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<ArticleEntity>)
@Query("UPDATE articles SET isBookmarked = :bookmarked WHERE id = :id")
suspend fun setBookmark(id: String, bookmarked: Boolean)
}
data class ArticleDto(val id: String, val title: String, val body: String)
interface NewsApi {
@GET("articles")
suspend fun getArticles(): List<ArticleDto>
}
fun ArticleEntity.toDomain() = Article(id, title, content, isBookmarked)
fun ArticleDto.toEntity() = ArticleEntity(id, title, body, isBookmarked = false)
class NewsRepositoryImpl @Inject constructor(
private val api: NewsApi,
private val dao: ArticleDao
) : NewsRepository {
override fun observeArticles(): Flow<List<Article>> =
dao.observeAll().map { list -> list.map { it.toDomain() } }
override suspend fun refreshArticles(): Result<Unit> = runCatching {
val remote = api.getArticles()
dao.insertAll(remote.map { it.toEntity() })
}
override suspend fun toggleBookmark(id: String) {
val current = dao.observeAll().first().first { it.id == id }
dao.setBookmark(id, !current.isBookmarked)
}
}
Presentation Layer
data class FeedUiState(
val articles: List<Article> = emptyList(),
val isRefreshing: Boolean = false,
val error: String? = null
)
@HiltViewModel
class FeedViewModel @Inject constructor(
getArticles: GetArticlesUseCase,
private val refreshArticles: RefreshArticlesUseCase,
private val toggleBookmark: ToggleBookmarkUseCase
) : ViewModel() {
val uiState: StateFlow<FeedUiState> = getArticles()
.map { FeedUiState(articles = it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), FeedUiState())
fun onRefresh() = viewModelScope.launch {
refreshArticles()
}
fun onBookmarkClick(id: String) = viewModelScope.launch {
toggleBookmark(id)
}
}
2.4 Hilt — Dependency Injection Wiring
flowchart TB
App[Application
HiltAndroidApp] --> AppComp[ApplicationComponent]
AppComp --> NetMod[NetworkModule
Provides Retrofit, OkHttp]
AppComp --> DbMod[DatabaseModule
Provides Room]
AppComp --> RepoMod[RepositoryModule
Binds Repo to Impl]
Act[Activity
AndroidEntryPoint] --> ActComp[ActivityComponent]
VM[ViewModel
HiltViewModel] --> VMComp[ViewModelComponent]
VMComp -.injects.-> UC[UseCase]
UC -.injects.-> Repo[Repository Interface]
RepoMod -.binds.-> Repo
Application class:
@HiltAndroidApp
class NewsApp : Application()
Network module:
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun provideOkHttp(): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor())
.build()
@Provides @Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(client)
.addConverterFactory(MoshiConverterFactory.create())
.build()
@Provides @Singleton
fun provideNewsApi(retrofit: Retrofit): NewsApi = retrofit.create()
}
Database module:
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides @Singleton
fun provideDatabase(@ApplicationContext ctx: Context): AppDatabase =
Room.databaseBuilder(ctx, AppDatabase::class.java, "news.db").build()
@Provides
fun provideArticleDao(db: AppDatabase): ArticleDao = db.articleDao()
}
Repository binding (interface → implementation):
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindNewsRepository(
impl: NewsRepositoryImpl
): NewsRepository
}
Activity entry point:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { NewsApp() }
}
}
2.5 Hilt Scopes Cheat Sheet
| Annotation | Lifetime | Use for |
|---|---|---|
@Singleton | App lifetime | Retrofit, Room, Repositories |
@ActivityRetainedScoped | Across config changes | ViewModel-shared deps |
@ViewModelScoped | Per ViewModel | Per-feature helpers |
@ActivityScoped | Per Activity | Activity-only utils |
@FragmentScoped | Per Fragment | Rare with Compose |
Part 3: Modularization
3.1 Why Modularize?
flowchart LR
subgraph Mono[Monolithic :app]
M1[Everything in one module
Slow builds
Tangled dependencies]
end
subgraph Multi[Modular Project]
F1[:feature:login]
F2[:feature:feed]
F3[:feature:bookmarks]
C1[:core:network]
C2[:core:database]
C3[:core:ui]
D[:domain]
DA[:data]
end
Mono -->|Refactor| Multi
Benefits:
| Benefit | How |
|---|---|
| Faster builds | Parallel + incremental compilation |
| Encapsulation | Hide implementation behind APIs |
| Team scaling | Teams own modules |
| Reusability | Use modules across apps |
| On-demand delivery | Dynamic feature modules |
3.2 Module Types
flowchart TB
APP[:app
Entry point
DI graph]
subgraph Features
F1[:feature:login]
F2[:feature:feed]
F3[:feature:bookmarks]
end
subgraph Core[Core Modules]
C1[:core:ui]
C2[:core:designsystem]
C3[:core:common]
end
subgraph Data[Data and Domain]
D[:core:domain]
DA[:core:data]
N[:core:network]
DB[:core:database]
end
APP --> F1
APP --> F2
APP --> F3
F1 --> C1
F2 --> C1
F3 --> C1
F1 --> D
F2 --> D
F3 --> D
DA --> N
DA --> DB
DA -.implements.-> D
APP --> DA
3.3 Folder Structure
NewsApp/
├── app/ ← entry point, wires everything
│ └── src/main/java/...App.kt
│
├── core/
│ ├── common/ ← Result, dispatchers, utils
│ ├── designsystem/ ← Theme, Color, Typography, components
│ ├── ui/ ← shared composables (LoadingView, ErrorView)
│ ├── domain/ ← entities, use cases, repo interfaces
│ ├── data/ ← repo implementations
│ ├── database/ ← Room DB + DAOs
│ └── network/ ← Retrofit + DTOs
│
└── feature/
├── login/ ← LoginScreen + LoginViewModel
├── feed/ ← FeedScreen + FeedViewModel
└── bookmarks/ ← BookmarksScreen + BookmarksViewModel
3.4 Module-Level build.gradle.kts
:feature:feed/build.gradle.kts:
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
namespace = "com.example.feature.feed"
compileSdk = 34
defaultConfig { minSdk = 24 }
buildFeatures { compose = true }
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:ui"))
implementation(project(":core:designsystem"))
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
}
:core:domain/build.gradle.kts (pure Kotlin — no Android!):
plugins {
alias(libs.plugins.kotlin.jvm)
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.javax.inject)
}
settings.gradle.kts:
include(":app")
include(":core:common")
include(":core:designsystem")
include(":core:ui")
include(":core:domain")
include(":core:data")
include(":core:database")
include(":core:network")
include(":feature:login")
include(":feature:feed")
include(":feature:bookmarks")
3.5 The API/Impl Pattern (Advanced)
Hide implementations behind interfaces so features depend on what, not how.
flowchart LR
F[:feature:feed] -->|depends on| API[:core:data-api
Interfaces only]
APP[:app] -->|provides| IMPL[:core:data-impl
Hilt Binds]
IMPL -.implements.-> API
This means a feature module can be compiled without knowing if Room or Realm is the backing store.
3.6 Navigation Across Modules
Each feature exposes its own navigation graph:
// :feature:feed
const val FEED_ROUTE = "feed"
fun NavGraphBuilder.feedScreen(onArticleClick: (String) -> Unit) {
composable(FEED_ROUTE) {
FeedScreen(onArticleClick = onArticleClick)
}
}
fun NavController.navigateToFeed() = navigate(FEED_ROUTE)
// :app — wires features together
@Composable
fun NewsNavHost(navController: NavHostController) {
NavHost(navController, startDestination = LOGIN_ROUTE) {
loginScreen(onLoginSuccess = { navController.navigateToFeed() })
feedScreen(onArticleClick = { id -> navController.navigateToArticle(id) })
bookmarksScreen()
}
}
Features never reference each other — only :app knows the full graph.
Part 4: Putting It All Together
4.1 Final Architecture Diagram
flowchart TB
subgraph App[:app]
AppEntry[Application + MainActivity
+ NavHost]
end
subgraph FeatureLayer[Feature Modules]
FL[:feature:login]
FF[:feature:feed]
FB[:feature:bookmarks]
end
subgraph UILayer[Presentation - inside each feature]
Comp[Composable Screens]
VM[ViewModels
StateFlow]
end
subgraph DomainLayer[:core:domain - Pure Kotlin]
UC[Use Cases]
Ent[Entities]
RepoIf[Repository Interfaces]
end
subgraph DataLayer[:core:data + :core:network + :core:database]
RepoImpl[Repository Impls]
Api[Retrofit APIs]
Dao[Room DAOs]
end
App --> FL
App --> FF
App --> FB
FL --> UILayer
FF --> UILayer
FB --> UILayer
Comp --> VM
VM --> UC
UC --> RepoIf
RepoImpl -.implements.-> RepoIf
RepoImpl --> Api
RepoImpl --> Dao
4.2 End-to-End Request Animation (Step by Step)
sequenceDiagram
autonumber
actor User
participant Screen as FeedScreen
(Composable)
participant VM as FeedViewModel
participant UC as RefreshArticlesUseCase
participant Repo as NewsRepositoryImpl
participant API as NewsApi (Retrofit)
participant DB as ArticleDao (Room)
participant State as UiState (StateFlow)
User->>Screen: Pull to refresh
Screen->>VM: onRefresh()
VM->>State: isRefreshing = true
State-->>Screen: recompose (spinner)
VM->>UC: invoke()
UC->>Repo: refreshArticles()
Repo->>API: GET /articles
API-->>Repo: List of ArticleDto
Repo->>DB: insertAll(entities)
DB-->>Repo: Flow emits new list
Repo-->>UC: Result.success
UC-->>VM: Result.success
VM->>State: isRefreshing = false, articles updated
State-->>Screen: recompose (new list)
Screen-->>User: shows fresh articles
4.3 Build Graph and Compilation Speedup
flowchart TB
APP[:app] --> FL[:feature:login]
APP --> FF[:feature:feed]
APP --> FB[:feature:bookmarks]
FL --> UI[:core:ui]
FF --> UI
FB --> UI
FL --> DOM[:core:domain]
FF --> DOM
FB --> DOM
APP --> DATA[:core:data]
DATA --> NET[:core:network]
DATA --> DB[:core:database]
DATA --> DOM
style FL fill:#1a3a5c
style FF fill:#1a3a5c
style FB fill:#1a3a5c
style DOM fill:#1a4d3a
style NET fill:#4d4d1a
style DB fill:#4d4d1a
Edit :feature:feed? Only :feature:feed + :app recompile. :feature:login, :feature:bookmarks, :core:network, :core:database are cached.
Part 5: Testing Strategy Across Layers
flowchart LR
subgraph Tests
U[Unit Tests
JUnit + MockK]
I[Integration Tests
Room in-memory
MockWebServer]
UI[UI Tests
Compose Test]
end
U --> Domain[Domain UseCases
ViewModels]
I --> Data[Repositories
DAOs
APIs]
UI --> Screens[Composable Screens]
@Test
fun `login success updates state to success`() = runTest {
val fakeRepo = FakeAuthRepository(success = true)
val useCase = LoginUseCase(fakeRepo)
val vm = LoginViewModel(useCase)
vm.onEmailChange("a@b.com")
vm.onPasswordChange("123")
vm.onLoginClick()
advanceUntilIdle()
assertTrue(vm.uiState.value.isLoginSuccess)
}
Part 6: Reference Project Structure (Now in Android style)
See the live modular News app this post is based on: github.com/mukeshkumar7470/Compose-News-App
flowchart TB
APP[":app
Application + Navigation"]
subgraph Features
L[":feature:login
Screen + VM + DI"]
F[":feature:feed
Screen + VM + DI"]
B[":feature:bookmarks
Screen + VM + DI"]
end
subgraph Core
C1[":core:ui
Shared composables"]
C2[":core:designsystem
Theme + tokens"]
C3[":core:common
Result, dispatchers"]
end
subgraph DomainData[Domain and Data]
D[":core:domain
UseCases + Entities"]
DT[":core:data
Repo Impls"]
N[":core:network
Retrofit + DTOs"]
DB[":core:database
Room + DAOs"]
end
APP --> L
APP --> F
APP --> B
APP --> DT
L --> D
F --> D
B --> D
L --> C1
F --> C1
B --> C1
C1 --> C2
DT --> D
DT --> N
DT --> DB
DT --> C3
Cheat Sheet: Layer to Responsibilities
| Layer | Module | Knows about | Doesn't know about |
|---|---|---|---|
| UI | :feature:* | ViewModel, Compose | Repository impl, DB, API |
| ViewModel | :feature:* | UseCases, UI state | Composables (no Context!) |
| Domain | :core:domain | Entities, Repo interfaces | Android, Retrofit, Room |
| Repository | :core:data | API + DB + Domain | UI, ViewModels |
| Data sources | :core:network, :core:database | DTOs, Entities | Domain, UI |
Recommended Learning Path
| Step | Goal | Resource |
|---|---|---|
| 1 | Build a single-module MVVM + Compose app | Android Codelab |
| 2 | Add Hilt + Repository + Room + Retrofit | Architecture Codelab |
| 3 | Split into :domain, :data, :app | Clean Architecture sample |
| 4 | Modularize by feature | Now in Android |
| 5 | Add MVI/UDF + Paging 3 + WorkManager | Production-grade app |
Next Steps
If you want to go even deeper, here are some areas worth exploring:
- A complete runnable mini-project layout — see the Compose News App repo
- MVI variant with
Intent+Reducer - Compose Navigation type-safe routes across modules
- Convention plugins (
build-logic) to share Gradle config across modules - Testing pyramid with full sample tests per layer
New to Android architecture?
Start with the absolute beginner guide that uses restaurant analogies and lots of diagrams to teach the same concepts from zero.