Kotlin Multiplatform + Compose Multiplatform conventions with Voyager, Koin, Ktor, Kotlinx Serialization
These standards apply when the project stack is kmp-compose. All agents and implementations must follow these conventions. This is a CROSS-PLATFORM stack targeting Android and iOS.
shared/): Business logic, data layer, networking, DI configuration — shared across all platforms.androidApp/, iosApp/): Platform-specific UI, ViewModels, navigation, storage implementations.data/ (repositories, API, storage) → domain/ (business logic, if needed) → presentation/ (ViewModels, screens).Use expect/actual for platform-specific implementations. Keep the shared API surface minimal:
// commonMain — declare the contract
expect class TokenStorage {
suspend fun getToken(): String?
suspend fun saveToken(token: String)
suspend fun clearToken()
}
// androidMain — Android implementation
actual class TokenStorage(private val context: Context) {
private val prefs = EncryptedSharedPreferences.create(...)
actual suspend fun getToken(): String? = prefs.getString("token", null)
actual suspend fun saveToken(token: String) { prefs.edit().putString("token", token).apply() }
actual suspend fun clearToken() { prefs.edit().remove("token").apply() }
}
// iosMain — iOS implementation
actual class TokenStorage {
actual suspend fun getToken(): String? = NSUserDefaults.standardUserDefaults.stringForKey("token")
// ...
}
Rule: Only use expect/actual when platform APIs genuinely differ. If the implementation is the same on all platforms, keep it in commonMain.
@Composable functions with typed parameters.collectAsState() to observe StateFlows from ViewModels.LaunchedEffect for one-time side effects (load data on first composition).remember and rememberSaveable for local UI state that survives recomposition/rotation.@Composable
fun HomeScreen(viewModel: HomeViewModel = koinViewModel()) {
val uiState by viewModel.uiState.collectAsState()
val isRefreshing by viewModel.isRefreshing.collectAsState()
LaunchedEffect(Unit) { viewModel.loadDashboard() }
when (val state = uiState) {
is HomeUiState.Loading -> LoadingSkeleton()
is HomeUiState.Success -> DashboardContent(
dashboard = state.dashboard,
isRefreshing = isRefreshing,
onRefresh = { viewModel.refresh() },
)
is HomeUiState.Error -> ErrorMessage(
message = state.message,
onRetry = { viewModel.loadDashboard() },
)
}
}
MaterialTheme, Surface, Card, Button, etc.) from androidx.compose.material3.ui/theme/ with Theme.kt, Color.kt, Type.kt.MaterialTheme.colorScheme.primary, .error, .surface) — never hardcode hex colors.isSystemInDarkTheme().modifier: Modifier = Modifier as the first optional parameter to all reusable composables.MutableStateFlow, public StateFlow — standard ViewModel pattern.update {} block for atomic state modifications.Loading, Success(data), Error(message), Empty.class InvoicesViewModel(
private val invoiceRepository: InvoiceRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow<InvoicesUiState>(InvoicesUiState.Loading)
val uiState: StateFlow<InvoicesUiState> = _uiState.asStateFlow()
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
fun loadInvoices(clientId: String) {
viewModelScope.launch {
_uiState.value = InvoicesUiState.Loading
invoiceRepository.getInvoices(clientId)
.onSuccess { _uiState.value = InvoicesUiState.Success(it) }
.onError { _uiState.value = InvoicesUiState.Error(it.message) }
}
}
}
sealed interface InvoicesUiState {
data object Loading : InvoicesUiState
data class Success(val invoices: List<Invoice>) : InvoicesUiState
data class Error(val message: String) : InvoicesUiState
}
Use a custom sealed Result<T> for all repository return types:
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Throwable, val message: String? = null) : Result<Nothing>()
}
// Extension functions for clean consumption
inline fun <T> Result<T>.onSuccess(action: (T) -> Unit): Result<T> { if (this is Result.Success) action(data); return this }
inline fun <T> Result<T>.onError(action: (Result.Error) -> Unit): Result<T> { if (this is Result.Error) action(this); return this }
// commonMain — shared dependencies
val commonModule = module {
single { Json { ignoreUnknownKeys = true; isLenient = true } }
single { SessionManager() }
single { createHttpClient(get(), get()) }
single { ApiClient(get()) }
// Repositories
single<AuthRepository> { AuthRepositoryImpl(get()) }
single<DashboardRepository> { DashboardRepositoryImpl(get()) }
single<InvoiceRepository> { InvoiceRepositoryImpl(get()) }
}
// androidMain — platform-specific + ViewModels
val appModule = module {
single { TokenStorage(androidContext()) }
single { PreferencesStorage(androidContext()) }
// ViewModels — use factory for parameterized construction
viewModel { HomeViewModel(get(), get(), get()) }
viewModel { params -> LoginPasswordViewModel(params.get(), get()) }
}
single {} for stateless services (repositories, API client, SessionManager).viewModel {} for ViewModels — lifecycle-aware, scoped to the composable.factory {} for objects that should be created fresh each time.koinViewModel() in composables to inject ViewModels.get() in composables — always inject via koinViewModel() or koinInject().Wrap Ktor with a typed API client:
class ApiClient(private val httpClient: HttpClient) {
suspend inline fun <reified T> get(endpoint: String, params: Map<String, String> = emptyMap()): Result<T> =
safeApiCall { httpClient.get("$baseUrl$endpoint") { params.forEach { (k, v) -> parameter(k, v) } } }
suspend inline fun <reified T, reified R> post(endpoint: String, body: R): Result<T> =
safeApiCall { httpClient.post("$baseUrl$endpoint") { setBody(body) } }
suspend inline fun <reified T> safeApiCall(block: () -> HttpResponse): Result<T> = try {
val response = block()
Result.Success(response.body<T>())
} catch (e: Exception) {
Result.Error(e, e.message)
}
}
fun createHttpClient(json: Json, sessionManager: SessionManager): HttpClient {
return HttpClient {
install(ContentNegotiation) { json(json) }
install(HttpTimeout) { requestTimeoutMillis = 30_000; connectTimeoutMillis = 15_000 }
install(Auth) {
bearer { loadTokens { sessionManager.token?.let { BearerTokens(it, "") } } }
}
// Logging interceptor
install(Logging) { level = LogLevel.HEADERS; logger = Logger.DEFAULT }
// Retry on transient failures
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 3)
exponentialDelay()
}
// 401 handling — clear session and redirect
HttpResponseValidator {
handleResponseExceptionWithRequest { exception, _ ->
if (exception is ClientRequestException && exception.response.status.value == 401) {
sessionManager.clearSession()
}
}
}
}
}
@Serializable annotation.@SerialName for snake_case mapping: @SerialName("client_id") val clientId: String.ignoreUnknownKeys = true, isLenient = true.data/api/models/ — separate from domain models if they diverge.@Composable
fun AppNavigator() {
TabNavigator(HomeTab) { tabNavigator ->
Scaffold(
bottomBar = {
NavigationBar {
listOf(HomeTab, InvoicesTab, GatesTab, ProfileTab).forEach { tab ->
NavigationBarItem(
selected = tabNavigator.current == tab,
onClick = { tabNavigator.current = tab },
icon = { Icon(tab.options.icon!!, contentDescription = tab.options.title) },
label = { Text(tab.options.title) },
)
}
}
}
) { innerPadding ->
CurrentTab(Modifier.padding(innerPadding))
}
}
}
object : Tab with TabOptions.Navigator for stack-based push/pop navigation within tabs.navigator.push(DetailScreen(id)) for forward navigation.navigator.pop() to go back.getScreenModel<MyScreenModel>() in Voyager screens.Use SQLDelight for type-safe local database access across platforms. It generates Kotlin APIs from SQL statements.
// build.gradle.kts (shared module)
plugins {
alias(libs.plugins.sqldelight)
}
sqldelight {
databases {
create("AppDatabase") {
packageName.set("com.app.cache")
}
}
}
// Dependencies per source set
commonMain.dependencies {
implementation("app.cash.sqldelight:runtime:2.1.0")
implementation("app.cash.sqldelight:coroutines-extensions:2.1.0")
}
androidMain.dependencies {
implementation("app.cash.sqldelight:android-driver:2.1.0")
}
iosMain.dependencies {
implementation("app.cash.sqldelight:native-driver:2.1.0")
}
// commonMain
interface DatabaseDriverFactory {
fun createDriver(): SqlDriver
}
// androidMain
class AndroidDatabaseDriverFactory(private val context: Context) : DatabaseDriverFactory {
override fun createDriver(): SqlDriver = AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
}
// iosMain
class IosDatabaseDriverFactory : DatabaseDriverFactory {
override fun createDriver(): SqlDriver = NativeSqliteDriver(AppDatabase.Schema, "app.db")
}
Place .sq files in shared/src/commonMain/sqldelight/:
-- Invoice.sq
CREATE TABLE Invoice (
id TEXT PRIMARY KEY,
clientId TEXT NOT NULL,
amount REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
createdAt TEXT NOT NULL
);
selectByClient:
SELECT * FROM Invoice WHERE clientId = ? ORDER BY createdAt DESC;