Patterns, structures and rules for building robust, testable, and maintainable Android/KMP applications.
Each layer has a single responsibility. The golden rule: domain depends on nothing.
Each pattern has a specific role. Combined together, they form a robust and testable architecture.
Pure Kotlin data class — zero framework dependencies. The business core of your app.
Contract defined in domain. Implemented by the data layer. ViewModel only sees the interface.
operator fun invoke() — encapsulates a business operation. Optional, used when logic is shared across ≥2 VMs.
Converts DTO ↔ Domain. Two styles: object Mapper or extension functions. Never in the ViewModel.
Concrete implementation using runCatching. Coordinates remote + local DataSources.
@Serializable data class mirroring the JSON API. Isolated in data/remote/dto/.
State holder injecting domain interfaces via Koin. Exposes a StateFlow<UiState>.
sealed interface: Loading | Success | Error. Always exhaustive in when.
Stateless composable. Receives data + callbacks. Separated from Route (connected to VM).
Wiring interface → impl at runtime. single for repos, viewModel for VMs.
Result<T> + runCatching or custom AppResult<T> sealed type for typed errors.
Test double implementing the domain interface. No mocking framework — pure Fakes.
From domain model to ViewModel — each layer has a distinct style.
// Zero framework imports data class Match( val homeTeam: Club, val awayTeam: Club, val homeScore: Int, val awayScore: Int, val status: MatchStatus, ) enum class MatchStatus { NOT_STARTED, LIVE, FINISHED }
object MatchMapper { fun toDomain(dto: FixtureDto): Match { return Match( homeTeam = mapTeam(dto.teams.home), awayTeam = mapTeam(dto.teams.away), homeScore = dto.goals.home ?: 0, awayScore = dto.goals.away ?: 0, status = mapStatus(dto.status), ) } }
class MatchesViewModel( private val repository: MatchRepository, ) : ViewModel() { val uiState = MutableStateFlow<UiState>(Loading) fun loadMatches(date: LocalDate) { viewModelScope.launch { repository.getMatches(date) .onSuccess { uiState.value = Success(it) } .onFailure { uiState.value = Error(it) } } } }
val repositoryModule = module { single<MatchRepository> { MatchRepositoryImpl(get()) } single<NewsRepository> { NewsRepositoryImpl(get()) } } val viewModelModule = module { viewModel { MatchesViewModel(get()) } viewModel { NewsViewModel(get()) } }
Events go down, data goes up. Never take shortcuts.
KMP-first stack — maximum shared code between Android and iOS.
What to absolutely avoid — and the right alternative.
| ❌ Don't | ✅ Do |
|---|---|
Import data.* in UI layer | Import only domain.model.* |
Business logic in @Composable | Extract to ViewModel or UseCase |
| DTOs as domain models | Separate domain models + mappers |
| Hilt/Dagger in KMP | Koin (multiplatform compatible) |
| Retrofit in KMP | Ktor Client |
GlobalScope | viewModelScope or structured concurrency |
| Fat repository with all logic | Split into focused DataSources |
ViewModel in screens/ | Separate viewmodel/ package |
Android framework in domain/ | Domain = pure Kotlin only |
| try/catch without mapping errors | runCatching or Result<T> |
Steps to follow for every new feature implementation.
5 detailed reference documents to deep-dive into each topic.
3 layers, DataSource, offline-first, expect/actual, convention plugins
State hoisting, shimmer, performance, remember, theme tokens
Single-module vs multi-module, KMP structure, dependency rules
Version catalog, Ktor/Retrofit config, bundles, build.gradle.kts
Fakes, ViewModel tests, Mapper tests, Turbine, runTest
Clone the repo and copy it into your IDE's skills folder.