Android architecture has converged. After years of competing patterns — MVP, MVC, MVVM, MVI — the community has settled on a clear, well-supported approach. This article presents the full picture: what to use at each layer, and why.
The Three Layers
The official Android architecture guide defines three layers with clear responsibilities:
- UI layer — displays state to the user and handles user events. ViewModel + Compose.
- Domain layer (optional) — encapsulates business logic in use-cases. Especially valuable in larger apps.
- Data layer — owns data sources (network, database, sensors) and exposes them as Flows via Repositories.
UI Layer: UDF with ViewModel
Unidirectional Data Flow: state flows down to the UI, events flow up to the ViewModel.
// State
data class FeedUiState(
val articles: List<Article> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
// ViewModel
class FeedViewModel(private val getArticles: GetArticlesUseCase) : ViewModel() {
val uiState: StateFlow<FeedUiState> = getArticles()
.map { FeedUiState(articles = it) }
.catch { e -> emit(FeedUiState(error = e.message)) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), FeedUiState(isLoading = true))
}
// UI
@Composable
fun FeedScreen(viewModel: FeedViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
FeedContent(state)
}
Domain Layer: Use-Cases
Use-cases (or interactors) contain application-specific business logic. Each use-case does one thing.
class GetArticlesUseCase(private val repo: ArticleRepository) {
operator fun invoke(): Flow<List<Article>> =
repo.getArticles()
.map { articles -> articles.filter { it.isPublished }.sortedByDescending { it.date } }
}
Data Layer: Repository Pattern
class ArticleRepository(
private val remoteDs: ArticleRemoteDataSource,
private val localDs: ArticleLocalDataSource
) {
fun getArticles(): Flow<List<Article>> = localDs.getAll()
suspend fun sync() {
val remote = remoteDs.fetchAll()
localDs.upsertAll(remote)
}
}
Dependency Injection with Hilt
Hilt is the standard DI framework for Android. It generates the DI graph at compile time, providing compile-time safety and minimal runtime overhead.
@HiltViewModel
class FeedViewModel @Inject constructor(
private val getArticles: GetArticlesUseCase
) : ViewModel()
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides @Singleton
fun provideArticleRepository(
remote: ArticleRemoteDataSource,
local: ArticleLocalDataSource
): ArticleRepository = ArticleRepository(remote, local)
}
Modularization
For apps beyond a certain size, modularization pays dividends in build speed and team scalability. The recommended module structure in 2026:
:app— application entry point only. Minimal code.:feature:feed,:feature:profile— one module per feature, owns UI and ViewModel.:domain— use-cases and domain models. No Android dependencies.:data:articles,:data:users— one module per data domain.:core:ui,:core:network,:core:database— shared infrastructure.
What to Avoid
- God ViewModels — a ViewModel that owns all state for a complex screen is hard to test and reason about. Split by responsibility.
- Skipping the domain layer in complex apps — when ViewModels directly call repository methods with complex logic, the logic is untestable without Android.
- Circular dependencies between modules — use a dependency graph linter or build time checks to enforce layer rules.