Clean Architecture is a layering strategy that protects business logic from framework details. In Android, this means your domain layer knows nothing about Retrofit, Room, or Compose. Only the outer layers do.
The central rule is the Dependency Rule: source code dependencies can only point inward. Inner layers define abstractions. Outer layers implement them.
The Three Layers
Presentation → Domain ← Data
Domain is the center. It contains:
- Entity / model classes (pure Kotlin, no Android imports)
- Use case / interactor classes
- Repository interfaces (contracts, not implementations)
Data implements domain contracts:
- Repository implementations
- Remote data sources (Retrofit)
- Local data sources (Room)
- DTOs and mappers
Presentation drives the UI:
- ViewModels
- UI state classes
- Composables
Domain Layer
The domain layer must compile without the Android SDK. This forces you to keep it pure.
// Pure Kotlin — no android imports
data class User(
val id: String,
val name: String,
val email: String
)
interface UserRepository {
suspend fun getUser(id: String): Result<User>
suspend fun updateUser(user: User): Result<Unit>
}
Use cases represent a single business action. They coordinate repositories and apply business rules.
class GetUserProfileUseCase(
private val userRepository: UserRepository,
private val analyticsRepository: AnalyticsRepository
) {
suspend operator fun invoke(userId: String): Result<User> {
analyticsRepository.logEvent("profile_viewed")
return userRepository.getUser(userId)
}
}
Using the invoke operator lets call sites look like getUser(id) instead of getUser.execute(id).
Data Layer
The data layer implements domain interfaces.
data class UserDto(
@SerialName("user_id") val id: String,
@SerialName("display_name") val name: String,
val email: String
)
fun UserDto.toUser() = User(id, name, email)
class UserRepositoryImpl(
private val remoteSource: UserRemoteSource,
private val localSource: UserLocalSource
) : UserRepository {
override suspend fun getUser(id: String): Result<User> = runCatching {
val cached = localSource.getUser(id)
if (cached != null) return@runCatching cached.toUser()
val dto = remoteSource.fetchUser(id)
localSource.saveUser(dto)
dto.toUser()
}
}
Never leak DTO types or Room entities into the domain layer. Map at the boundary.
Presentation Layer
ViewModels call use cases, not repositories directly.
class ProfileViewModel(
private val getUserProfile: GetUserProfileUseCase
) : ViewModel() {
private val _state = MutableStateFlow(ProfileUiState())
val state: StateFlow<ProfileUiState> = _state.asStateFlow()
fun loadProfile(userId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
getUserProfile(userId)
.onSuccess { user ->
_state.update { it.copy(user = user, isLoading = false) }
}
.onFailure { error ->
_state.update { it.copy(error = error.message, isLoading = false) }
}
}
}
}
When to Skip a Layer
Clean Architecture adds boilerplate. For small or prototype apps, a repository + ViewModel is enough. Add use cases when:
- Multiple ViewModels share the same business logic
- Business rules involve more than one data source
- You need to test logic independently of framework code
Do not cargo-cult layers. Add them when the complexity justifies the structure.
Practice
Take an existing ViewModel that calls a repository directly. Extract the business logic into a use case. Then move the repository interface into a domain module and create a data module that implements it.
Summary
Clean Architecture enforces the Dependency Rule: domain defines contracts, data and presentation implement them. Business logic becomes framework-independent, testable in pure Kotlin, and shielded from API or database changes.