Coding Standards — Android / KMP
Binding for any project that adopts it — overrides tool defaults. Stack: Kotlin, Compose (Multiplatform or Android-only), MVI, Clean Architecture, SOLID. Audience: humans and AI agents — this is the single source of truth.
0. The Loop — memorize, never violate
Composable --Intent--> ViewModel.onIntent(i)
├─ reduce(state,i) -> newState PURE. sync. total. no I/O.
├─ _state.update { newState } atomic, single call
└─ effect path: _effects.send(e) Channel, single-pass
Composable <--state-- StateFlow<State> (collectAsStateWithLifecycle)
Composable <--effect-- Flow<Effect> (Channel.receiveAsFlow, consume once)
State flows down, Intent flows up, Effect is a one-shot side channel (navigate/toast/clipboard/haptic) delivered exactly once.
1. Universal rules (all layers)
| # | Rule |
|---|---|
| U1 | Every async/fallible API returns a sealed result wrapper or Flow<Result<T>>. Never throw across a layer boundary. |
| U2 | Constructor injection only. No service locators, no stateful object singletons, no GlobalScope. |
| U3 | Depend on interfaces, not impls. Impls are internal/private. |
| U4 | Immutability by default: val, data class, read-only collections in public signatures. |
| U5 | No magic values. Strings/numbers/dimensions → named constants, string resources, or design tokens. |
| U6 | One public type per file; file name = type name. |
| U7 | No TODO/stub/catch-all else -> {} that swallows a case in production paths. |
| U8 | No secrets/PII in logs. Encrypt sensitive data before persistence or transmission. |
| U9 | Dispatchers via an injected provider. Never hardcode Dispatchers.* in business code. |
2. SOLID — concrete obligations
| Principle | Obligation |
|---|---|
| SRP | One reason to change per class. ViewModel orchestrates; UseCase = one operation; Repository = one aggregate. No god "manager" classes. |
| OCP | Extend via new UseCase / new impl / new strategy — not by editing switch-ladders. |
| LSP | Every fake/test double must honor the real contract (same result semantics, same ordering). |
| ISP | Small role interfaces — consumers depend only on methods they use. |
| DIP | High-level code depends on abstractions; impls injected at the composition root. |
3. Layer specs
3.1 Domain — pure Kotlin, zero platform deps
- Entities: immutable data class; value objects over primitives where it prevents bugs.
- Repository interfaces live here; impls live in data.
- Use cases: one
operator fun invoke. Pure business rules.
class GetCartTotalUseCase(private val repo: CartRepository) {
suspend operator fun invoke(id: CartId): Result<Money> = repo.total(id)
}
3.2 Data — single source of truth is local; impls internal
- Reads from the local store, exposed as Flow. UI/use cases never read remote directly.
- Writes (offline-capable): local first → enqueue remote sync.
- Remote is isolated — SDK imports live only in the data/remote layer.
- Mappers are the DTO↔entity boundary; one mapper per entity, one direction pair.
3.3 Presentation — ViewModel
Extends MviViewModel<State, Intent, Effect> from compose-utils (KMP-ready). dispatch(intent) is the single entry point.
class CartViewModel(
private val getTotal: GetCartTotalUseCase,
private val dispatchers: DispatcherProvider,
) : MviViewModel<CartState, CartIntent, CartEffect>(CartState()) {
override suspend fun onIntent(intent: CartIntent) = when (intent) {
CartIntent.Load -> {
updateState { copy(isLoading = true, error = null) }
val result = withContext(dispatchers.io) { getTotal(state.value.cartId) }
result.fold(
onSuccess = { total -> updateState { copy(isLoading = false, total = total) } },
onFailure = { err -> updateState { copy(isLoading = false, error = err) }
sendEffect(CartEffect.ShowError(err)) },
)
}
is CartIntent.QueryChanged -> updateState { copy(query = intent.q) }
is CartIntent.ItemTapped -> sendEffect(CartEffect.NavigateToItem(intent.id))
}
}
updateState { }is the only way to change state — atomic viaStateFlow.update.sendEffect(e)posts to aChannel.BUFFERED— consumed exactly once by the UI.onIntentis total over the sealed Intent — every branch handled.- For screens with no user-driven intents, use
MviStateViewModel<State, Effect>instead.
3.4 Presentation — State / Intent / Effect contracts
// State — single immutable source of truth for the screen.
data class CartState(
val isLoading: Boolean = false,
val items: List<CartItem> = emptyList(),
val query: String = "",
val total: Money? = null,
val error: AppError? = null,
) {
val visibleItems: List<CartItem> get() = items.filter { it.matches(query) }
val isEmpty: Boolean get() = !isLoading && visibleItems.isEmpty()
}
// Intent — every user action AND every async result. Sealed.
sealed interface CartIntent {
data object Load : CartIntent
data class Loaded(val total: Money) : CartIntent
data class QueryChanged(val q: String) : CartIntent
data class ItemTapped(val id: ItemId) : CartIntent
}
// Effect — one-shot only (nav/toast/clipboard/haptic). Channel only.
sealed interface CartEffect {
data class NavigateToItem(val id: ItemId) : CartEffect
data class ShowMessage(val text: UiText) : CartEffect
}
3.5 UI — Composable: stateless, dumb
// Route: the ONLY place that touches the ViewModel.
@Composable
fun CartRoute(vm: CartViewModel = viewModel(), nav: Navigator) {
val state by vm.state.collectAsStateWithLifecycle()
vm.collectEffects { effect ->
when (effect) {
is CartEffect.NavigateToItem -> nav.toItem(effect.id)
is CartEffect.ShowError -> snackbarHost.showSnackbar(effect.message)
}
}
CartScreen(state, onIntent = vm::dispatch)
}
// Content: pure UI. State in, Intent out. No VM, no business logic, no I/O.
@Composable
fun CartScreen(state: CartState, onIntent: (CartIntent) -> Unit) {
LaunchedEffect(Unit) { onIntent(CartIntent.Load) }
when {
state.isLoading -> LoadingView()
state.error != null -> ErrorView(state.error, onRetry = { onIntent(CartIntent.Load) })
else -> CartList(state.visibleItems, onIntent)
}
}
- No business logic in composables — read finished values off
state's computed props. - No ViewModel reach-in below the Route. Content composables receive
state+(Intent) -> Unitonly. - Hoist state to the max. No
remember { mutableStateOf(...) }holding business/UI state. - Stateless + previewable. Every content composable renders from a hand-built State; add
@Preview(light + dark). - Colors/type/spacing via theme tokens. No hardcoded hex / magic dp.
4. Naming & style
- Files:
{Name}Screen.kt,{Name}ViewModel.kt,{Name}State.kt,{Name}Intent.kt,{Name}Effect.kt,{Name}RepositoryImpl.kt,{Verb}{Noun}UseCase.kt,{Name}Mapper.kt. PascalCasetypes,camelCasemembers,UPPER_SNAKEconsts.- Booleans read as predicates (
isLoading,hasError,canSubmit). - Lint clean (ktlint/detekt/Android lint) — no suppression without an inline justification comment.
5. Testing
See the dedicated Testing page for patterns and coverage targets.
6. Pre-commit self-check
[ ] onIntent() total over sealed Intent: every branch handled, no silent else -> Unit
[ ] updateState { } is the only state mutation; no mutable fields / side-channel writes
[ ] No business logic / formatting / filtering inside any @Composable
[ ] State hoisted: no business-state remember{}; derived values are computed get() props
[ ] dispatch(intent) up, collectEffects { } down; effects consumed exactly once
[ ] All async returns the result wrapper; errors surface to State; no silent catch
[ ] Reads from local store; remote/SDK isolated to data layer; backend swappable
[ ] Sensitive data encrypted before persist; nothing secret/PII logged
[ ] Constructor DI; interfaces public, impls internal; no GlobalScope
[ ] Names/files per convention; tokens/resources (no magic values); lint clean
[ ] Unit test added for onIntent handler / use case