Android Architecture Components
Introduction
Building a maintainable Android app requires more than just making things work — it demands a clear separation of concerns, testable components, and a predictable data flow. Over the years, the Android ecosystem has converged around a set of architectural guidelines known as Android Architecture Components. When combined with modern Kotlin practices, these patterns help you write apps that are easier to debug, test, and scale. In this tutorial, we'll walk through a complete architecture using MVVM (Model-View-ViewModel), unidirectional data flow, the Repository pattern, Hilt for dependency injection, and a three-layer structure: ui, domain, and data. By the end, you'll have a solid understanding of how all these pieces fit together in a real-world project.
Project Structure: ui, domain, and data layers
A well-organized project starts with a clear directory structure. We'll split our app into three high-level packages: data handles all external interactions — API calls, local databases, and network checks. The domain package contains pure business logic: use cases, domain models, and repository interfaces. The ui package holds everything the user sees — activities, fragments, ViewModels, and composables or layouts. This separation ensures that each layer only depends on the layer below it, never the reverse.
Here's what a typical Gradle setup looks like for an app using Jetpack Compose, Hilt, and Room:
// app/build.gradle.kts (or build.gradle)
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("kotlin-kapt")
}
android {
namespace = "com.example.archapp"
compileSdk = 34
defaultConfig {
applicationId = "com.example.archapp"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("com.google.dagger:hilt-android:2.50")
kapt("com.google.dagger:hilt-compiler:2.50")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
implementation("androidx.navigation:navigation-compose:2.7.6")
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
}
This configuration pulls in all the core Jetpack libraries along with Hilt for dependency injection, Room for local persistence, and Compose for building the UI. Note that kapt is required for both Hilt and Room's code generators, which produce boilerplate classes at compile time.
Domain layer: use cases and repository interfaces
The domain layer is where business logic lives, completely independent of Android frameworks. It defines what the app does, not how it does it. We declare interfaces for repositories here so that the ViewModel depends only on abstractions, not concrete implementations. Use cases encapsulate a single piece of business logic — for example, "fetch the user's order history" or "validate a login attempt."
// domain/usecase/GetProductsUseCase.kt
package com.example.archapp.domain.usecase
import com.example.archapp.domain.model.Product
import com.example.archapp.domain.repository.ProductRepository
import kotlinx.coroutines.flow.Flow
class GetProductsUseCase(
private val repository: ProductRepository
) {
fun execute(): Flow<List<Product>> = repository.getProducts()
}
// domain/repository/ProductRepository.kt
package com.example.archapp.domain.repository
import com.example.archapp.domain.model.Product
import kotlinx.coroutines.flow.Flow
interface ProductRepository {
fun getProducts(): Flow<List<Product>>
suspend fun getProductById(id: String): Product?
}
// domain/model/Product.kt
package com.example.archapp.domain.model
data class Product(
val id: String,
val name: String,
val price: Double,
val description: String
)
This layer uses Kotlin's Flow to represent streams of data that may change over time, such as a list of products that gets refreshed from the network. Because ProductRepository is an interface, we can swap out the network implementation for a mock or cache without touching any upstream code. The GetProductsUseCase is intentionally thin — it simply delegates to the repository, keeping the logic reusable and testable.
Data layer: repositories, DTOs, and local storage
The data layer is responsible for fetching data from remote sources and persisting it locally. It translates between API response objects (DTOs) and domain models. We implement the ProductRepository interface defined in the domain layer and inject dependencies like Retrofit or Room DAOs to perform the actual work. A local database is useful for offline support and reducing unnecessary network calls.
// data/datasource/RemoteProductDataSource.kt
package com.example.archapp.data.datasource
import com.example.archapp.data.dto.ProductDto
import retrofit2.http.GET
interface RemoteProductDataSource {
@GET("products")
suspend fun getProducts(): List<ProductDto>
}
// data/dto/ProductDto.kt
package com.example.archapp.data.dto
import com.google.gson.annotations.SerializedName
data class ProductDto(
@SerializedName("id") val id: String,
@SerializedName("name") val name: String,
@SerializedName("price") val price: Double,
@SerializedName("description") val description: String
)
// data/repository/ProductRepositoryImpl.kt
package com.example.archapp.data.repository
import com.example.archapp.data.datasource.RemoteProductDataSource
import com.example.archapp.data.dto.toDomain
import com.example.archapp.domain.model.Product
import com.example.archapp.domain.repository.ProductRepository
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class ProductRepositoryImpl @Inject constructor(
private val remoteDataSource: RemoteProductDataSource
) : ProductRepository {
override fun getProducts(): Flow<List<Product>> =
remoteDataSource.getProducts()
.map { dtos -> dtos.map { it.toDomain() } }
override suspend fun getProductById(id: String): Product? =
remoteDataSource.getProducts()
.firstOrNull { it.id == id }
?.toDomain()
}
// data/dto/ProductDtoExtensions.kt
package com.example.archapp.data.dto
import com.example.archapp.domain.model.Product
fun ProductDto.toDomain() = Product(
id = id,
name = name,
price = price,
description = description
)
In this setup, the repository implementation bridges the gap between the HTTP response and the domain model. The RemoteProductDataSource is a Retrofit interface that returns DTOs — plain data containers with no business logic. The toDomain() extension function handles the conversion. By returning a Flow from getProducts(), we enable reactive updates without coupling the data layer to the UI thread or lifecycle.
ViewModel with unidirectional data flow
MVVM stands for Model-View-ViewModel. The ViewModel holds the UI state and exposes it through observable streams, typically as Kotlin Flows or StateFlows. The View (a Composable or Fragment) observes this state and re-composes or re-renders when it changes. Unidirectional data flow means that events travel upward from the View, get handled by the ViewModel, and the resulting state travels back down — never sideways. The ViewModel never touches the View directly; it only emits state.
// ui/viewmodel/ProductViewModel.kt
package com.example.archapp.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.archapp.domain.model.Product
import com.example.archapp.domain.usecase.GetProductsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed class ProductUiState {
object Loading : ProductUiState()
data class Success(val products: List<Product>) : ProductUiState()
data class Error(val message: String) : ProductUiState()
}
@HiltViewModel
class ProductViewModel @Inject constructor(
private val getProductsUseCase: GetProductsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<ProductUiState>(ProductUiState.Loading)
val uiState: StateFlow<ProductUiState> = _uiState.asStateFlow()
init {
loadProducts()
}
fun loadProducts() {
viewModelScope.launch {
_uiState.value = ProductUiState.Loading
try {
val products = getProductsUseCase.execute().first()
_uiState.value = ProductUiState.Success(products)
} catch (e: Exception) {
_uiState.value = ProductUiState.Error(e.message ?: "Unknown error")
}
}
}
}
The ProductViewModel uses Hilt to inject the GetProductsUseCase, keeping it free from Android framework dependencies. The UI state is wrapped in a sealed class, which provides exhaustive type safety when the View handles each possible state. MutableStateFlow is exposed publicly as an immutable StateFlow so that only the ViewModel can mutate it. When loadProducts() is called, the ViewModel transitions from Loading to Success or Error, and the UI reacts accordingly.
Dependency injection with Hilt
Hilt is the recommended DI framework for Android. It builds on top of Dagger but removes much of the manual boilerplate by providing pre-built components for Android types like Activity, Fragment, and ViewModel. We annotate the application class with @HiltAndroidApp, mark repository implementations with @Singleton and @Inject, and annotate ViewModels with @HiltViewModel so Hilt can generate the proper injection graph at compile time.
// app/build.gradle.kts - Hilt plugin must be applied
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("kotlin-kapt")
}
// di/AppModule.kt
package com.example.archapp.di
import com.example.archapp.data.datasource.RemoteProductDataSource
import com.example.archapp.data.repository.ProductRepositoryImpl
import com.example.archapp.domain.repository.ProductRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class AppModule {
@Binds
@Singleton
abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository
}
// di/NetworkModule.kt
package com.example.archapp.di
import com.example.archapp.data.datasource.RemoteProductDataSource
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit =
Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
@Provides
@Singleton
fun provideRemoteDataSource(retrofit: Retrofit): RemoteProductDataSource =
retrofit.create(RemoteProductDataSource::class.java)
}
// MainActivity.kt
package com.example.archapp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.example.archapp.ui.theme.ArchAppTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ArchAppTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
// App navigation and screens go here
}
}
}
}
}
Hilt's @Module classes tell the DI container how to construct specific objects. The AppModule binds the ProductRepository interface to its concrete implementation, ensuring that whenever something asks for a ProductRepository, it receives ProductRepositoryImpl. The NetworkModule provides the Retrofit instance and the data source. @AndroidEntryPoint on MainActivity tells Hilt to inject into the Activity, and because ViewModels are also marked with @HiltViewModel, they automatically receive their dependencies from the same graph.
UI layer with Jetpack Compose
The UI layer reads from the ViewModel's StateFlow and displays the results. With Jetpack Compose, this is particularly clean because Compose automatically recomposes when observed state changes. We collect the StateFlow using collectAsStateWithLifecycle(), which is lifecycle-aware and safely handles rotation and configuration changes. Event handlers are functions that the View calls to trigger actions in the ViewModel, such as refreshing data or handling a button press.
// ui/screen/ProductScreen.kt
package com.example.archapp.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.archapp.domain.model.Product
import com.example.archapp.ui.viewmodel.ProductUiState
import com.example.archapp.ui.viewmodel.ProductViewModel
@Composable
fun ProductScreen(
viewModel: ProductViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
is ProductUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
CircularProgressIndicator()
}
}
is ProductUiState.Success -> {
ProductList(products = state.products, onRetry = { viewModel.loadProducts() })
}
is ProductUiState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
Text(text = state.message, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadProducts() }) {
Text("Retry")
}
}
}
}
}
}
@Composable
fun ProductList(
products: List<Product>,
onRetry: () -> Unit
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 16.dp)
) {
items(products, key = { it.id }) { product ->
ProductCard(product = product)
}
}
}
@Composable
fun ProductCard(product: Product) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
Text(text = product.name, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
Text(text = product.description, style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "$${product.price}",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
The ProductScreen composable collects the ViewModel's state and renders different UI based on the current state using a when expression. Because ProductUiState is a sealed class, the compiler guarantees that every possible case is handled — there's no risk of a missed branch. The onRetry lambda demonstrates unidirectional event flow: the View doesn't call the ViewModel directly; instead, it passes a callback that the View model executes. hiltViewModel() fetches the ViewModel from the Hilt graph, automatically providing all its injected dependencies.
Common Pitfalls
One of the most frequent mistakes is leaking Android context into the domain layer. Never pass Context, Activity, or ViewModel references into use cases or repository interfaces — the domain layer should remain framework-agnostic. Another common error is mutating state directly from the View. State should always be owned by the ViewModel; the View only reads and triggers events. Additionally, forgetting to expose StateFlow as an immutable StateFlow (rather than MutableStateFlow) allows external code to mutate state unexpectedly. Finally, be careful with lifecycle-aware collection in Compose — always use collectAsStateWithLifecycle() rather than the plain collectAsState() to avoid memory leaks and unnecessary recompositions during configuration changes.
Conclusion
Building an Android app with a proper architecture takes a little more upfront effort, but the payoff in maintainability, testability, and team collaboration is enormous. By separating concerns across data, domain, and ui layers, using MVVM with unidirectional data flow, relying on the Repository pattern to abstract data sources, and wiring everything together with Hilt, you create a codebase that is resilient to change. New team members can understand the project structure immediately, unit tests can exercise business logic in isolation, and UI changes never accidentally break data handling. Start small — even extracting one use case or one ViewModel into this pattern is a meaningful step toward a cleaner, more professional Android codebase.