Coding

Clean Architecture for Android: Structuring a Kotlin Codebase That Scales

Ahmad Najar Ahmad Najar 2 min read

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, navigation

Domain 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.

Coding