Clean Architecture sounds intimidating — UseCases, Repositories, Mappers everywhere. But after shipping multiple production Android apps, I've found a simplified version that keeps code maintainable without the ceremony.
The 3 Layers That Matter
The core idea is dependency direction: outer layers depend on inner layers, never the reverse. In practice for Android: Presentation → Domain → Data.
- Data layer: Retrofit/Room implementations, remote & local data sources, Repository implementations
- Domain layer: Repository interfaces, UseCase classes, plain Kotlin models
- Presentation layer: ViewModels, Compose UI, UiState sealed classes
The UseCase Pattern
UseCases are single-responsibility classes that encapsulate one piece of business logic. They're easy to test and make your ViewModel clean.
// Domain layer — pure Kotlin, no Android imports
class GetUserProfileUseCase(
private val userRepository: UserRepository
) {
suspend operator fun invoke(userId: String): Result<UserProfile> {
return userRepository.getUserProfile(userId)
}
}
// ViewModel just orchestrates
class ProfileViewModel(
private val getUserProfile: GetUserProfileUseCase
) : ViewModel() {
fun loadProfile(id: String) = viewModelScope.launch {
_uiState.value = getUserProfile(id).fold(
onSuccess = { UiState.Success(it) },
onFailure = { UiState.Error(it.message) }
)
}
}
Common Pitfalls to Avoid
- Don't create a UseCase for every single CRUD operation — group related operations
- Don't put business logic in Composables or ViewModels — it belongs in UseCases
- Don't skip the domain layer — it's what makes your code testable
💡 In Luna Owner, we rebuilt the entire app with this architecture pattern. Result: test coverage jumped from 0% to 65%, and feature development became measurably faster.
Conclusion
Start with the 3-layer structure. Add UseCases where business logic is non-trivial. Keep Composables dumb — they should only transform UiState into UI. That's it.