Back to Blog

MVVM + Compose, Clean Architecture + Hilt, Modularization

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:

  • LoginContent is 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

AnnotationLifetimeUse for
@SingletonApp lifetimeRetrofit, Room, Repositories
@ActivityRetainedScopedAcross config changesViewModel-shared deps
@ViewModelScopedPer ViewModelPer-feature helpers
@ActivityScopedPer ActivityActivity-only utils
@FragmentScopedPer FragmentRare 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:

BenefitHow
Faster buildsParallel + incremental compilation
EncapsulationHide implementation behind APIs
Team scalingTeams own modules
ReusabilityUse modules across apps
On-demand deliveryDynamic 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

LayerModuleKnows aboutDoesn't know about
UI:feature:*ViewModel, ComposeRepository impl, DB, API
ViewModel:feature:*UseCases, UI stateComposables (no Context!)
Domain:core:domainEntities, Repo interfacesAndroid, Retrofit, Room
Repository:core:dataAPI + DB + DomainUI, ViewModels
Data sources:core:network, :core:databaseDTOs, EntitiesDomain, UI

Recommended Learning Path

StepGoalResource
1Build a single-module MVVM + Compose appAndroid Codelab
2Add Hilt + Repository + Room + RetrofitArchitecture Codelab
3Split into :domain, :data, :appClean Architecture sample
4Modularize by featureNow in Android
5Add MVI/UDF + Paging 3 + WorkManagerProduction-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.

View Project Beginner Guide Interview Prep All Posts