Clean Architecture for Android: Structuring a Kotlin Codebase That Scales
Clean Architecture, Minus the Dogma
"Clean Architecture" gets a bad reputation for producing 40 files to fetch a list of users. Used pragmatically, though, the core idea — dependencies point inward, business rules don't know about frameworks — genuinely pays off once an app has more than one or two engineers on it.
The three layers, in practice
com.app/
├── data/ // repositories, API/DB implementations, DTOs
├── domain/ // use cases, domain models, repository interfaces
└── presentation/ // ViewModels, Compose UI, navigationDomain layer: the part that shouldn't know Android exists
// domain/GetUserProfileUseCase.kt
class GetUserProfileUseCase(private val repository: ProfileRepository) {
suspend operator fun invoke(userId: String): Result =
repository.getProfile(userId)
}
// domain/ProfileRepository.kt — an interface, no implementation here
interface ProfileRepository {
suspend fun getProfile(userId: String): Result
}Notice: no Retrofit, no Room, no Android imports anywhere in this layer. This is what makes it testable without a single mock of an Android framework class, and portable if you later share it via KMM.
Data layer: where the framework-specific mess actually lives
class ProfileRepositoryImpl(
private val api: ApiClient,
private val dao: ProfileDao
) : ProfileRepository {
override suspend fun getProfile(userId: String): Result = try {
val dto = api.getProfile(userId)
dao.insert(dto.toEntity())
Result.success(dto.toDomainModel())
} catch (e: Exception) {
dao.getById(userId)?.let { Result.success(it.toDomainModel()) }
?: Result.failure(e)
}
}This is the layer that changes when you swap Retrofit for Ktor, or Room for SQLDelight — and none of that change should ripple into your ViewModels or use cases.
Presentation layer: thin ViewModels, not fat ones
class ProfileViewModel(
private val getUserProfile: GetUserProfileUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState.Loading)
val uiState = _uiState.asStateFlow()
fun load(userId: String) = viewModelScope.launch {
_uiState.value = getUserProfile(userId)
.fold(ProfileUiState::Success, ProfileUiState::Error)
}
}The ViewModel orchestrates; it doesn't contain business rules. If a use case is reused by two different screens, this is exactly where that reuse should live — not duplicated logic across two ViewModels.
What to skip, honestly
- Don't create a use case for every single CRUD operation if there's genuinely no business logic beyond "call the repository." A thin pass-through use case adds indirection with no payoff.
- Don't over-abstract with interfaces for things you'll only ever have one implementation of (e.g. a single local DB). Interfaces earn their keep when you actually need to swap implementations or mock for tests.
- Do keep the domain layer free of Android/Kotlin-Android-specific imports — this is the one rule worth enforcing strictly, since it's what keeps the core logic portable and independently testable.
Judge the architecture by whether a new engineer can find where a bug lives without reading the whole codebase — not by how closely it matches a textbook diagram.