| name | best-practices |
| description | Kotlin Multiplatform performance optimization and clean architecture patterns. This skill should be used when writing, reviewing, or refactoring KMP code to ensure optimal performance patterns. Triggers on tasks involving Compose Multiplatform, Ktor client, Koin DI, coroutines, or platform-specific code. |
| license | MIT |
| metadata | {"author":"devnogari","project_type":"kotlin-multiplatform"} |
Kotlin Multiplatform Best Practices
Comprehensive performance optimization guide for Kotlin Multiplatform (KMP) applications with Compose Multiplatform, Ktor, and Koin. Contains 44 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
When to Apply
Reference these guidelines when:
- Writing Compose Multiplatform UI components
- Implementing state management with StateFlow/MutableStateFlow
- Setting up Koin dependency injection modules
- Writing Ktor client HTTP requests
- Creating expect/actual platform abstractions
- Managing coroutine scopes and structured concurrency
- Implementing ViewModel patterns
- Writing tests for Compose and coroutines
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|
| 1 | Compose State | CRITICAL | compose-state- |
| 2 | Coroutines | CRITICAL | coroutine- |
| 3 | Koin DI | CRITICAL | koin- |
| 4 | Ktor Client | HIGH | ktor- |
| 5 | Expect/Actual | HIGH | expect- |
| 6 | ViewModel | HIGH | viewmodel- |
| 7 | Navigation | MEDIUM | nav- |
| 8 | Testing | MEDIUM | test- |
Quick Reference
1. Compose State (CRITICAL)
compose-state-flow - Use StateFlow with collectAsState for reactive UI
compose-state-remember - Use remember/rememberSaveable correctly
compose-state-effect - Use LaunchedEffect/DisposableEffect properly
compose-state-derived - Use derivedStateOf for computed values
compose-state-stable - Mark classes with @Stable/@Immutable
compose-state-key - Use key() to preserve state across recompositions
2. Coroutines (CRITICAL)
coroutine-scope - Use appropriate coroutine scope hierarchy
coroutine-structured - Follow structured concurrency principles
coroutine-cancel - Handle cancellation properly
coroutine-exception - Use CoroutineExceptionHandler correctly
coroutine-dispatcher - Use correct dispatchers (Main, IO, Default)
coroutine-flow - Use Flow operators correctly (stateIn, shareIn)
3. Koin DI (CRITICAL)
koin-module - Organize modules by feature/layer
koin-scope - Use proper scoping (single, factory, viewModel)
koin-inject - Inject dependencies correctly in Compose
koin-qualifier - Use named qualifiers for disambiguation
koin-lazy - Use lazy injection for optional dependencies
koin-test - Mock dependencies properly in tests
4. Ktor Client (HIGH)
ktor-client - Configure HttpClient properly
ktor-error - Handle HTTP errors and exceptions
ktor-serialization - Configure JSON serialization correctly
ktor-timeout - Set appropriate timeouts
ktor-retry - Implement retry logic for transient failures
ktor-logging - Configure request/response logging
5. Expect/Actual (HIGH)
expect-interface - Prefer interfaces over expect/actual when possible
expect-minimal - Keep expect declarations minimal
expect-default - Provide default implementations where appropriate
expect-platform - Use platform-specific optimizations
expect-test - Test platform implementations independently
6. ViewModel (HIGH)
viewmodel-state - Use sealed class/interface for UI state
viewmodel-event - Separate one-time events from state
viewmodel-loading - Handle loading/error/success states
viewmodel-scope - Use viewModelScope for coroutines
viewmodel-save - Save/restore state across process death
7. Navigation (MEDIUM)
nav-typesafe - Use type-safe navigation arguments
nav-deeplink - Handle deep links correctly
nav-backstack - Manage back stack properly
nav-result - Pass results between screens safely
nav-compose - Integrate with Compose navigation
8. Testing (MEDIUM)
test-compose - Write Compose UI tests correctly
test-coroutine - Test coroutines with TestDispatcher
test-flow - Test Flow emissions with Turbine
test-koin - Setup Koin for testing
test-ktor - Mock Ktor client responses
Detailed Rules
1. Compose State (CRITICAL)
compose-state-flow - Use StateFlow with collectAsState
StateFlow provides lifecycle-aware state collection that survives recomposition. Always use collectAsState() in composables.
Incorrect (using mutableStateOf in ViewModel):
class UserViewModel : ViewModel() {
var user by mutableStateOf<User?>(null)
private set
fun loadUser() {
viewModelScope.launch {
user = repository.getUser()
}
}
}
@Composable
fun UserScreen(viewModel: UserViewModel) {
val user = viewModel.user
}
Correct (using StateFlow):
class UserViewModel : ViewModel() {
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user.asStateFlow()
fun loadUser() {
viewModelScope.launch {
_user.value = repository.getUser()
}
}
}
@Composable
fun UserScreen(viewModel: UserViewModel) {
val user by viewModel.user.collectAsState()
user?.let { UserContent(it) }
}
Impact: Lifecycle-aware collection, proper state restoration, thread safety.
compose-state-remember - Use remember/rememberSaveable correctly
Use remember for expensive calculations, rememberSaveable for state that survives configuration changes.
Incorrect (recreating expensive objects):
@Composable
fun ItemList(items: List<Item>) {
val sortedItems = items.sortedBy { it.name }
val filteredItems = sortedItems.filter { it.isActive }
LazyColumn {
items(filteredItems) { item ->
ItemRow(item)
}
}
}
Correct (using remember with keys):
@Composable
fun ItemList(items: List<Item>) {
val processedItems = remember(items) {
items.filter { it.isActive }.sortedBy { it.name }
}
var searchQuery by rememberSaveable { mutableStateOf("") }
val filteredItems = remember(processedItems, searchQuery) {
if (searchQuery.isEmpty()) processedItems
else processedItems.filter { it.name.contains(searchQuery, ignoreCase = true) }
}
LazyColumn {
items(filteredItems, key = { it.id }) { item ->
ItemRow(item)
}
}
}
Impact: 2-10x reduction in unnecessary calculations.
compose-state-effect - Use LaunchedEffect/DisposableEffect properly
Use LaunchedEffect for suspending operations, DisposableEffect for cleanup.
Incorrect (side effects in composition):
@Composable
fun AnalyticsScreen(screenName: String, viewModel: AnalyticsViewModel) {
viewModel.trackScreenView(screenName)
val listener = object : SomeListener {
override fun onEvent(event: Event) { }
}
someService.addListener(listener)
}
Correct (using effects):
@Composable
fun AnalyticsScreen(screenName: String, viewModel: AnalyticsViewModel) {
LaunchedEffect(screenName) {
viewModel.trackScreenView(screenName)
}
DisposableEffect(Unit) {
val listener = object : SomeListener {
override fun onEvent(event: Event) { }
}
someService.addListener(listener)
onDispose {
someService.removeListener(listener)
}
}
}
compose-state-derived - Use derivedStateOf for computed values
Use derivedStateOf when you need computed state that only updates when dependencies change.
Incorrect (recalculating on every recomposition):
@Composable
fun ShoppingCart(items: List<CartItem>) {
val totalPrice = items.sumOf { it.price * it.quantity }
val itemCount = items.sumOf { it.quantity }
Text("Total: $$totalPrice ($itemCount items)")
}
Correct (using derivedStateOf):
@Composable
fun ShoppingCart(items: List<CartItem>) {
val totalPrice by remember {
derivedStateOf { items.sumOf { it.price * it.quantity } }
}
val itemCount by remember {
derivedStateOf { items.sumOf { it.quantity } }
}
Text("Total: $$totalPrice ($itemCount items)")
}
compose-state-stable - Mark classes with @Stable/@Immutable
Mark data classes as @Stable or @Immutable to enable Compose compiler optimizations.
Incorrect (unstable parameters cause recomposition):
data class User(
val id: String,
val name: String,
val avatar: String
)
@Composable
fun UserCard(user: User) {
Card {
Text(user.name)
AsyncImage(user.avatar)
}
}
Correct (marking as Immutable):
@Immutable
data class User(
val id: String,
val name: String,
val avatar: String
)
@Stable
class UserState(
val user: User,
private var _isLoading: Boolean = false
) {
val isLoading: Boolean get() = _isLoading
}
@Composable
fun UserCard(user: User) {
Card {
Text(user.name)
AsyncImage(user.avatar)
}
}
Impact: 50-90% reduction in unnecessary recompositions.
compose-state-key - Use key() to preserve state across recompositions
Use key() composable to maintain identity and state when items change position.
Incorrect (state lost when items reorder):
@Composable
fun TodoList(items: List<TodoItem>) {
Column {
items.forEach { item ->
TodoItemRow(item)
}
}
}
Correct (using key for identity):
@Composable
fun TodoList(items: List<TodoItem>) {
Column {
items.forEach { item ->
key(item.id) {
TodoItemRow(item)
}
}
}
}
@Composable
fun TodoList(items: List<TodoItem>) {
LazyColumn {
items(items, key = { it.id }) { item ->
TodoItemRow(item)
}
}
}
2. Coroutines (CRITICAL)
coroutine-scope - Use appropriate coroutine scope hierarchy
Use proper scope hierarchy: viewModelScope for ViewModels, rememberCoroutineScope for Compose.
Incorrect (creating unmanaged scopes):
class DataRepository {
private val scope = CoroutineScope(Dispatchers.IO)
fun fetchData() {
scope.launch {
}
}
}
Correct (managed scope hierarchy):
class DataRepository(
private val scope: CoroutineScope
) {
fun fetchData() = scope.launch {
}
}
class MyViewModel(
private val repository: DataRepository
) : ViewModel() {
fun loadData() {
viewModelScope.launch {
repository.fetchData().join()
}
}
}
@Composable
fun MyScreen() {
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch {
}
}) {
Text("Load")
}
}
coroutine-structured - Follow structured concurrency principles
Use structured concurrency: parent-child relationships, proper cancellation propagation.
Incorrect (breaking structured concurrency):
class OrderService {
suspend fun processOrder(order: Order) {
GlobalScope.launch {
sendConfirmationEmail(order)
}
CoroutineScope(Dispatchers.IO).launch {
updateInventory(order)
}
}
}
Correct (structured concurrency):
class OrderService {
suspend fun processOrder(order: Order) = coroutineScope {
val emailJob = launch {
sendConfirmationEmail(order)
}
val inventoryJob = launch {
updateInventory(order)
}
emailJob.join()
inventoryJob.join()
}
suspend fun processOrderParallel(order: Order) = coroutineScope {
val emailDeferred = async { sendConfirmationEmail(order) }
val inventoryDeferred = async { updateInventory(order) }
emailDeferred.await()
inventoryDeferred.await()
}
}
coroutine-cancel - Handle cancellation properly
Check for cancellation and clean up resources when coroutines are cancelled.
Incorrect (ignoring cancellation):
suspend fun processLargeFile(file: File): Result {
val lines = file.readLines()
var processed = 0
for (line in lines) {
processLine(line)
processed++
}
return Result(processed)
}
Correct (cancellation-aware):
suspend fun processLargeFile(file: File): Result {
val lines = file.readLines()
var processed = 0
for (line in lines) {
ensureActive()
processLine(line)
processed++
if (processed % 100 == 0) {
yield()
}
}
return Result(processed)
}
suspend fun downloadWithCleanup(url: String, tempFile: File) {
try {
downloadTo(url, tempFile)
} finally {
withContext(NonCancellable) {
if (!isActive) {
tempFile.delete()
}
}
}
}
coroutine-exception - Use CoroutineExceptionHandler correctly
Handle exceptions at appropriate scope levels with CoroutineExceptionHandler.
Incorrect (exceptions silently swallowed or crash):
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
val data = repository.fetchData()
_state.value = data
}
}
}
Correct (proper exception handling):
class MyViewModel : ViewModel() {
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
_state.value = UiState.Error(throwable.message ?: "Unknown error")
logger.error("ViewModel error", throwable)
}
fun loadData() {
viewModelScope.launch(exceptionHandler) {
_state.value = UiState.Loading
try {
val data = repository.fetchData()
_state.value = UiState.Success(data)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_state.value = UiState.Error(e.message ?: "Failed to load")
}
}
}
}
coroutine-dispatcher - Use correct dispatchers
Use Dispatchers.Main for UI, Dispatchers.IO for blocking I/O, Dispatchers.Default for CPU-intensive work.
Incorrect (blocking Main thread):
class FileRepository {
suspend fun readFile(path: String): String {
return File(path).readText()
}
}
Correct (proper dispatcher usage):
class FileRepository(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun readFile(path: String): String = withContext(ioDispatcher) {
File(path).readText()
}
suspend fun processData(data: List<Int>): List<Int> = withContext(Dispatchers.Default) {
data.map { heavyComputation(it) }
}
}
class MyViewModel(private val repo: FileRepository) : ViewModel() {
fun loadFile(path: String) {
viewModelScope.launch {
val content = repo.readFile(path)
_content.value = content
}
}
}
coroutine-flow - Use Flow operators correctly
Use stateIn/shareIn for sharing flows, proper terminal operators.
Incorrect (creating new flow on each collection):
class UserRepository(private val api: UserApi) {
fun getUsers(): Flow<List<User>> = flow {
val users = api.fetchUsers()
emit(users)
}
}
class UsersViewModel(private val repo: UserRepository) : ViewModel() {
val users = repo.getUsers()
}
Correct (sharing with stateIn):
class UserRepository(private val api: UserApi) {
fun getUsers(): Flow<List<User>> = flow {
val users = api.fetchUsers()
emit(users)
}
}
class UsersViewModel(private val repo: UserRepository) : ViewModel() {
val users: StateFlow<List<User>> = repo.getUsers()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
private val _events = MutableSharedFlow<UiEvent>()
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
}
3. Koin DI (CRITICAL)
koin-module - Organize modules by feature/layer
Structure Koin modules by feature or architectural layer for maintainability.
Incorrect (everything in one module):
val appModule = module {
single { UserRepository() }
single { ProductRepository() }
single { OrderRepository() }
viewModel { UserViewModel(get()) }
viewModel { ProductViewModel(get()) }
viewModel { OrderViewModel(get(), get()) }
single { HttpClient() }
single { Database() }
}
Correct (organized by feature/layer):
val coreModule = module {
single { createHttpClient() }
single { createDatabase() }
}
val userDataModule = module {
single<UserRepository> { UserRepositoryImpl(get()) }
single { UserApi(get()) }
}
val productDataModule = module {
single<ProductRepository> { ProductRepositoryImpl(get()) }
single { ProductApi(get()) }
}
val userPresentationModule = module {
viewModel { UserViewModel(get()) }
viewModel { UserListViewModel(get()) }
}
val productPresentationModule = module {
viewModel { ProductViewModel(get()) }
viewModel { ProductListViewModel(get(), get()) }
}
val allModules = listOf(
coreModule,
userDataModule, productDataModule,
userPresentationModule, productPresentationModule
)
koin-scope - Use proper scoping
Use single for singletons, factory for new instances, viewModel for ViewModels.
Incorrect (wrong scoping):
val module = module {
factory { HttpClient() }
single { UserViewModel(get()) }
factory { UserRepository(get()) }
}
Correct (proper scoping):
val module = module {
single { createHttpClient() }
single { Database() }
factory { DateFormatter() }
factory<Logger> { ConsoleLogger() }
viewModel { UserViewModel(get()) }
viewModel { params -> DetailViewModel(params.get(), get()) }
single<UserRepository> { UserRepositoryImpl(get(), get()) }
}
koin-inject - Inject dependencies correctly in Compose
Use koinViewModel() and koinInject() in Compose, avoid injecting at composition time.
Incorrect (injecting during composition):
@Composable
fun UserScreen() {
val viewModel = get<UserViewModel>()
val repository = KoinPlatformTools.defaultContext().get<UserRepository>()
}
Correct (proper Compose injection):
@Composable
fun UserScreen(
viewModel: UserViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsState()
UserContent(state)
}
@Composable
fun UserScreen(
userId: String,
viewModel: UserDetailViewModel = koinViewModel { parametersOf(userId) }
) {
val state by viewModel.state.collectAsState()
UserContent(state)
}
@Composable
fun FormattedDate(timestamp: Long) {
val formatter: DateFormatter = koinInject()
Text(formatter.format(timestamp))
}
koin-qualifier - Use named qualifiers for disambiguation
Use named/qualifier annotations to differentiate same-type dependencies.
Incorrect (ambiguous dependencies):
val module = module {
single { OkHttpClient() }
single { OkHttpClient().newBuilder().addInterceptor(authInterceptor).build() }
}
Correct (using qualifiers):
val module = module {
single(named("default")) {
OkHttpClient.Builder().build()
}
single(named("authenticated")) {
OkHttpClient.Builder()
.addInterceptor(get<AuthInterceptor>())
.build()
}
}
class ApiClient(
private val client: OkHttpClient
) { }
val apiModule = module {
single { ApiClient(get(named("authenticated"))) }
}
koin-lazy - Use lazy injection for optional dependencies
Use inject() with lazy for optional or conditionally-used dependencies.
Incorrect (eager injection of optional dependencies):
class AnalyticsService(
private val firebaseAnalytics: FirebaseAnalytics,
private val mixpanel: Mixpanel
) {
fun track(event: Event) {
firebaseAnalytics.log(event)
mixpanel.track(event)
}
}
Correct (lazy/optional injection):
class AnalyticsService : KoinComponent {
private val firebaseAnalytics: FirebaseAnalytics? by injectOrNull()
private val mixpanel: Mixpanel? by injectOrNull()
fun track(event: Event) {
firebaseAnalytics?.log(event)
mixpanel?.track(event)
}
}
class AnalyticsService(
private val providers: List<AnalyticsProvider> = emptyList()
) {
fun track(event: Event) {
providers.forEach { it.track(event) }
}
}
val module = module {
single {
AnalyticsService(getAll())
}
}
4. Ktor Client (HIGH)
ktor-client - Configure HttpClient properly
Create a properly configured HttpClient with all necessary plugins.
Incorrect (minimal configuration):
val client = HttpClient()
Correct (full configuration):
val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
coerceInputValues = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 30_000
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.HEADERS
filter { request ->
request.url.host.contains("api.example.com")
}
}
defaultRequest {
header(HttpHeaders.ContentType, ContentType.Application.Json)
header("X-App-Version", BuildConfig.VERSION_NAME)
}
expectSuccess = true
HttpResponseValidator {
handleResponseExceptionWithRequest { exception, _ ->
when (exception) {
is ClientRequestException -> throw ApiException.Client(exception)
is ServerResponseException -> throw ApiException.Server(exception)
else -> throw exception
}
}
}
}
ktor-error - Handle HTTP errors and exceptions
Implement comprehensive error handling for network requests.
Incorrect (no error handling):
class UserApi(private val client: HttpClient) {
suspend fun getUser(id: String): User {
return client.get("$BASE_URL/users/$id").body()
}
}
Correct (comprehensive error handling):
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val exception: ApiException) : ApiResult<Nothing>()
}
sealed class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause) {
class Network(cause: Throwable) : ApiException("Network error", cause)
class Timeout(cause: Throwable) : ApiException("Request timed out", cause)
class Client(val code: Int, message: String) : ApiException("Client error: $code - $message")
class Server(val code: Int, message: String) : ApiException("Server error: $code - $message")
class Parse(cause: Throwable) : ApiException("Failed to parse response", cause)
class Unknown(cause: Throwable) : ApiException("Unknown error", cause)
}
class UserApi(private val client: HttpClient) {
suspend fun getUser(id: String): ApiResult<User> = safeApiCall {
client.get("$BASE_URL/users/$id").body()
}
private suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> {
return try {
ApiResult.Success(block())
} catch (e: CancellationException) {
throw e
} catch (e: HttpRequestTimeoutException) {
ApiResult.Error(ApiException.Timeout(e))
} catch (e: ClientRequestException) {
val body = e.response.bodyAsText()
ApiResult.Error(ApiException.Client(e.response.status.value, body))
} catch (e: ServerResponseException) {
val body = e.response.bodyAsText()
ApiResult.Error(ApiException.Server(e.response.status.value, body))
} catch (e: SerializationException) {
ApiResult.Error(ApiException.Parse(e))
} catch (e: IOException) {
ApiResult.Error(ApiException.Network(e))
} catch (e: Exception) {
ApiResult.Error(ApiException.Unknown(e))
}
}
}
ktor-serialization - Configure JSON serialization correctly
Use kotlinx.serialization with proper configuration.
Incorrect (default serialization with crashes):
val json = Json.Default
@Serializable
data class User(
val id: String,
val name: String,
val email: String
)
Correct (resilient serialization):
val json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
encodeDefaults = false
explicitNulls = false
}
@Serializable
data class User(
val id: String,
val name: String,
val email: String? = null,
val role: UserRole = UserRole.USER
)
@Serializable
enum class UserRole {
@SerialName("admin") ADMIN,
@SerialName("user") USER,
@SerialName("guest") GUEST
}
ktor-retry - Implement retry logic for transient failures
Add retry logic for network requests that may fail temporarily.
Correct implementation:
suspend fun <T> retryWithBackoff(
times: Int = 3,
initialDelayMs: Long = 100,
maxDelayMs: Long = 10000,
factor: Double = 2.0,
shouldRetry: (Exception) -> Boolean = { it.isRetryable() },
block: suspend () -> T
): T {
var currentDelay = initialDelayMs
repeat(times - 1) { attempt ->
try {
return block()
} catch (e: Exception) {
if (!shouldRetry(e)) throw e
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelayMs)
}
}
return block()
}
private fun Exception.isRetryable(): Boolean = when (this) {
is CancellationException -> false
is HttpRequestTimeoutException -> true
is ConnectException -> true
is ServerResponseException -> response.status.value in 500..599
else -> false
}
class UserApi(private val client: HttpClient) {
suspend fun getUser(id: String): User = retryWithBackoff {
client.get("$BASE_URL/users/$id").body()
}
}
5. Expect/Actual (HIGH)
expect-interface - Prefer interfaces over expect/actual
Use interfaces with platform-specific implementations when possible.
Incorrect (overusing expect/actual):
expect class PlatformLogger() {
fun log(message: String)
fun error(message: String, throwable: Throwable?)
}
actual class PlatformLogger {
actual fun log(message: String) = Log.d(TAG, message)
actual fun error(message: String, throwable: Throwable?) = Log.e(TAG, message, throwable)
}
actual class PlatformLogger {
actual fun log(message: String) = NSLog(message)
actual fun error(message: String, throwable: Throwable?) = NSLog("$message: $throwable")
}
Correct (interface with expect factory):
interface Logger {
fun log(message: String)
fun error(message: String, throwable: Throwable? = null)
}
expect fun createLogger(): Logger
actual fun createLogger(): Logger = AndroidLogger()
class AndroidLogger : Logger {
override fun log(message: String) = Log.d(TAG, message)
override fun error(message: String, throwable: Throwable?) = Log.e(TAG, message, throwable)
}
actual fun createLogger(): Logger = IosLogger()
class IosLogger : Logger {
override fun log(message: String) = NSLog(message)
override fun error(message: String, throwable: Throwable?) = NSLog("$message: $throwable")
}
Impact: Better testability, easier DI integration.
expect-minimal - Keep expect declarations minimal
Only use expect/actual for truly platform-specific code.
Incorrect (expect for common logic):
expect fun formatDate(timestamp: Long): String
actual fun formatDate(timestamp: Long): String {
val date = Date(timestamp)
val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
return format.format(date)
}
actual fun formatDate(timestamp: Long): String {
val date = NSDate.dateWithTimeIntervalSince1970(timestamp.toDouble() / 1000)
val formatter = NSDateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.stringFromDate(date)
}
Correct (minimal expect, common logic):
expect class DateFormatter() {
fun format(timestamp: Long, pattern: String): String
}
fun formatDate(timestamp: Long): String = DateFormatter().format(timestamp, "yyyy-MM-dd")
fun formatDateTime(timestamp: Long): String = DateFormatter().format(timestamp, "yyyy-MM-dd HH:mm")
fun formatTime(timestamp: Long): String = DateFormatter().format(timestamp, "HH:mm")
import kotlinx.datetime.*
fun formatDate(timestamp: Long): String {
val instant = Instant.fromEpochMilliseconds(timestamp)
val dateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
return "${dateTime.year}-${dateTime.monthNumber.toString().padStart(2, '0')}-${dateTime.dayOfMonth.toString().padStart(2, '0')}"
}
6. ViewModel (HIGH)
viewmodel-state - Use sealed class/interface for UI state
Model UI state as a sealed hierarchy for exhaustive handling.
Incorrect (multiple boolean flags):
class UserViewModel : ViewModel() {
var isLoading by mutableStateOf(false)
var error by mutableStateOf<String?>(null)
var user by mutableStateOf<User?>(null)
var isEmpty by mutableStateOf(false)
}
Correct (sealed UI state):
sealed interface UserUiState {
data object Loading : UserUiState
data class Success(val user: User) : UserUiState
data class Error(val message: String, val retry: () -> Unit) : UserUiState
data object Empty : UserUiState
}
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow<UserUiState>(UserUiState.Loading)
val state: StateFlow<UserUiState> = _state.asStateFlow()
fun loadUser(userId: String) {
viewModelScope.launch {
_state.value = UserUiState.Loading
repository.getUser(userId)
.onSuccess { user ->
_state.value = if (user != null) {
UserUiState.Success(user)
} else {
UserUiState.Empty
}
}
.onFailure { error ->
_state.value = UserUiState.Error(
message = error.message ?: "Unknown error",
retry = { loadUser(userId) }
)
}
}
}
}
@Composable
fun UserScreen(viewModel: UserViewModel) {
val state by viewModel.state.collectAsState()
when (val currentState = state) {
is UserUiState.Loading -> LoadingIndicator()
is UserUiState.Success -> UserContent(currentState.user)
is UserUiState.Error -> ErrorMessage(currentState.message, currentState.retry)
is UserUiState.Empty -> EmptyMessage()
}
}
viewmodel-event - Separate one-time events from state
Use SharedFlow for one-time events (navigation, snackbars) instead of state.
Incorrect (events in state):
data class LoginState(
val isLoading: Boolean = false,
val navigateToHome: Boolean = false,
val showSnackbar: String? = null
)
Correct (separate events):
data class LoginState(
val isLoading: Boolean = false,
val email: String = "",
val password: String = ""
)
sealed interface LoginEvent {
data object NavigateToHome : LoginEvent
data class ShowSnackbar(val message: String) : LoginEvent
data class ShowError(val error: String) : LoginEvent
}
class LoginViewModel(private val authRepository: AuthRepository) : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> = _state.asStateFlow()
private val _events = MutableSharedFlow<LoginEvent>()
val events: SharedFlow<LoginEvent> = _events.asSharedFlow()
fun login() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
authRepository.login(_state.value.email, _state.value.password)
.onSuccess {
_events.emit(LoginEvent.NavigateToHome)
}
.onFailure { error ->
_state.update { it.copy(isLoading = false) }
_events.emit(LoginEvent.ShowError(error.message ?: "Login failed"))
}
}
}
}
@Composable
fun LoginScreen(
viewModel: LoginViewModel,
onNavigateToHome: () -> Unit
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is LoginEvent.NavigateToHome -> onNavigateToHome()
is LoginEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
is LoginEvent.ShowError -> snackbarHostState.showSnackbar(event.error)
}
}
}
LoginContent(state, viewModel::login)
}
7. Navigation (MEDIUM)
nav-typesafe - Use type-safe navigation arguments
Use type-safe route definitions with proper argument handling.
Correct implementation (Voyager/Decompose style):
sealed class Screen : Parcelable {
@Parcelize
data object Home : Screen()
@Parcelize
data class UserDetail(val userId: String) : Screen()
@Parcelize
data class ProductDetail(val productId: String, val source: String? = null) : Screen()
}
class RootNavigator {
private val _screenStack = MutableStateFlow<List<Screen>>(listOf(Screen.Home))
val screenStack: StateFlow<List<Screen>> = _screenStack.asStateFlow()
fun push(screen: Screen) {
_screenStack.update { it + screen }
}
fun pop(): Boolean {
if (_screenStack.value.size <= 1) return false
_screenStack.update { it.dropLast(1) }
return true
}
fun replaceAll(screen: Screen) {
_screenStack.value = listOf(screen)
}
}
@Composable
fun RootContent(navigator: RootNavigator = koinInject()) {
val screens by navigator.screenStack.collectAsState()
screens.lastOrNull()?.let { screen ->
when (screen) {
is Screen.Home -> HomeScreen(
onUserClick = { userId -> navigator.push(Screen.UserDetail(userId)) },
onProductClick = { productId -> navigator.push(Screen.ProductDetail(productId)) }
)
is Screen.UserDetail -> UserDetailScreen(
userId = screen.userId,
onBack = { navigator.pop() }
)
is Screen.ProductDetail -> ProductDetailScreen(
productId = screen.productId,
source = screen.source,
onBack = { navigator.pop() }
)
}
}
}
nav-backstack - Manage back stack properly
Handle back navigation and back stack manipulation correctly.
Correct implementation:
class Navigator {
private val _backStack = MutableStateFlow<List<Screen>>(listOf(Screen.Home))
val currentScreen: StateFlow<Screen> = _backStack.map { it.last() }.stateIn()
fun navigate(screen: Screen, popUpTo: Screen? = null, inclusive: Boolean = false) {
_backStack.update { stack ->
val newStack = if (popUpTo != null) {
val index = stack.indexOfLast { it == popUpTo }
if (index >= 0) {
stack.take(if (inclusive) index else index + 1)
} else stack
} else stack
newStack + screen
}
}
fun popBackStack(): Boolean {
if (_backStack.value.size <= 1) return false
_backStack.update { it.dropLast(1) }
return true
}
fun onBackPressed(): Boolean {
return popBackStack()
}
}
@Composable
fun AppContent(navigator: Navigator) {
BackHandler(enabled = navigator.canGoBack) {
navigator.popBackStack()
}
}
8. Testing (MEDIUM)
test-compose - Write Compose UI tests correctly
Use ComposeTestRule for testing Compose UI components.
Correct implementation:
class UserCardTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun userCard_displaysUserInfo() {
val user = User(id = "1", name = "John Doe", email = "john@example.com")
composeTestRule.setContent {
MaterialTheme {
UserCard(user = user)
}
}
composeTestRule.onNodeWithText("John Doe").assertIsDisplayed()
composeTestRule.onNodeWithText("john@example.com").assertIsDisplayed()
}
@Test
fun userCard_callsOnClick_whenTapped() {
var clicked = false
val user = User(id = "1", name = "John", email = "john@example.com")
composeTestRule.setContent {
MaterialTheme {
UserCard(user = user, onClick = { clicked = true })
}
}
composeTestRule.onNodeWithText("John").performClick()
assertTrue(clicked)
}
@Test
fun userCard_showsLoadingState() {
composeTestRule.setContent {
MaterialTheme {
UserCard(isLoading = true)
}
}
composeTestRule.onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate))
.assertIsDisplayed()
}
}
test-coroutine - Test coroutines with TestDispatcher
Use TestDispatcher for deterministic coroutine testing.
Correct implementation:
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var viewModel: UserViewModel
private lateinit var repository: FakeUserRepository
@BeforeTest
fun setup() {
Dispatchers.setMain(testDispatcher)
repository = FakeUserRepository()
viewModel = UserViewModel(repository)
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun loadUser_emitsLoadingThenSuccess() = runTest {
val user = User("1", "John")
repository.setUser(user)
val states = mutableListOf<UserUiState>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.state.toList(states)
}
viewModel.loadUser("1")
advanceUntilIdle()
assertEquals(
listOf(UserUiState.Loading, UserUiState.Success(user)),
states.drop(1)
)
job.cancel()
}
@Test
fun loadUser_emitsError_onFailure() = runTest {
repository.setShouldFail(true)
viewModel.loadUser("1")
advanceUntilIdle()
assertTrue(viewModel.state.value is UserUiState.Error)
}
}
test-flow - Test Flow emissions with Turbine
Use Turbine library for testing Flow emissions.
Correct implementation:
class UserRepositoryTest {
@Test
fun observeUsers_emitsUpdates() = runTest {
val repository = UserRepositoryImpl(fakeDatabase)
repository.observeUsers().test {
assertEquals(emptyList<User>(), awaitItem())
repository.addUser(User("1", "John"))
assertEquals(listOf(User("1", "John")), awaitItem())
repository.addUser(User("2", "Jane"))
assertEquals(
listOf(User("1", "John"), User("2", "Jane")),
awaitItem()
)
cancelAndConsumeRemainingEvents()
}
}
@Test
fun observeUser_completesOnDeletion() = runTest {
val repository = UserRepositoryImpl(fakeDatabase)
repository.addUser(User("1", "John"))
repository.observeUser("1").test {
assertEquals(User("1", "John"), awaitItem())
repository.deleteUser("1")
awaitComplete()
}
}
}
test-koin - Setup Koin for testing
Configure Koin properly for unit and integration tests.
Correct implementation:
class UserViewModelKoinTest : KoinTest {
private val mockRepository: UserRepository = mockk()
@get:Rule
val koinTestRule = KoinTestRule.create {
modules(
module {
single<UserRepository> { mockRepository }
viewModel { UserViewModel(get()) }
}
)
}
private val viewModel: UserViewModel by inject()
@Test
fun loadUser_callsRepository() = runTest {
val user = User("1", "John")
coEvery { mockRepository.getUser("1") } returns Result.success(user)
viewModel.loadUser("1")
coVerify { mockRepository.getUser("1") }
}
}
class ManualKoinTest : KoinTest {
@BeforeTest
fun setup() {
startKoin {
modules(testModule)
}
}
@AfterTest
fun tearDown() {
stopKoin()
}
}
test-ktor - Mock Ktor client responses
Use MockEngine for testing Ktor client code.
Correct implementation:
class UserApiTest {
@Test
fun getUser_parsesResponse() = runTest {
val mockEngine = MockEngine { request ->
when {
request.url.encodedPath == "/users/1" -> respond(
content = ByteReadChannel("""{"id":"1","name":"John","email":"john@example.com"}"""),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
else -> respond(
content = ByteReadChannel(""),
status = HttpStatusCode.NotFound
)
}
}
val client = HttpClient(mockEngine) {
install(ContentNegotiation) { json() }
}
val api = UserApi(client)
val result = api.getUser("1")
assertEquals(User("1", "John", "john@example.com"), result)
}
@Test
fun getUser_handlesError() = runTest {
val mockEngine = MockEngine {
respond(
content = ByteReadChannel("""{"error":"User not found"}"""),
status = HttpStatusCode.NotFound
)
}
val client = HttpClient(mockEngine) {
install(ContentNegotiation) { json() }
}
val api = UserApi(client)
assertFailsWith<ClientRequestException> {
api.getUser("999")
}
}
}
Integration Workflow
When Writing Compose UI
- Use StateFlow with collectAsState (
compose-state-flow)
- Mark data classes as @Immutable (
compose-state-stable)
- Use remember with keys for expensive calculations (
compose-state-remember)
- Use LaunchedEffect for side effects (
compose-state-effect)
When Setting Up DI
- Organize modules by feature (
koin-module)
- Use proper scoping (single, factory, viewModel) (
koin-scope)
- Use koinViewModel() in Compose (
koin-inject)
- Add qualifiers for same-type dependencies (
koin-qualifier)
When Making Network Requests
- Configure HttpClient properly (
ktor-client)
- Implement comprehensive error handling (
ktor-error)
- Add retry logic for transient failures (
ktor-retry)
- Use resilient serialization configuration (
ktor-serialization)
When Writing ViewModels
- Use sealed class for UI state (
viewmodel-state)
- Separate one-time events from state (
viewmodel-event)
- Use viewModelScope for coroutines (
viewmodel-scope)
- Handle all loading/error/success states (
viewmodel-loading)
References