Architecture & Standards

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
U1Every async/fallible API returns a sealed result wrapper or Flow<Result<T>>. Never throw across a layer boundary.
U2Constructor injection only. No service locators, no stateful object singletons, no GlobalScope.
U3Depend on interfaces, not impls. Impls are internal/private.
U4Immutability by default: val, data class, read-only collections in public signatures.
U5No magic values. Strings/numbers/dimensions → named constants, string resources, or design tokens.
U6One public type per file; file name = type name.
U7No TODO/stub/catch-all else -> {} that swallows a case in production paths.
U8No secrets/PII in logs. Encrypt sensitive data before persistence or transmission.
U9Dispatchers via an injected provider. Never hardcode Dispatchers.* in business code.

2. SOLID — concrete obligations

PrincipleObligation
SRPOne reason to change per class. ViewModel orchestrates; UseCase = one operation; Repository = one aggregate. No god "manager" classes.
OCPExtend via new UseCase / new impl / new strategy — not by editing switch-ladders.
LSPEvery fake/test double must honor the real contract (same result semantics, same ordering).
ISPSmall role interfaces — consumers depend only on methods they use.
DIPHigh-level code depends on abstractions; impls injected at the composition root.

3. Layer specs

3.1 Domain — pure Kotlin, zero platform deps

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

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))
    }
}

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)
    }
}

4. Naming & style

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