brainstorming
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or planning anything new, in software or out of it (a talk, a business, a renovation).
mobile-kotlin-multiplatformUse this skill when the user says 'Kotlin Multiplatform', 'KMP', 'Compose Multiplatform', 'shared Kotlin', 'KMP module', 'expect/actual', 'commonMain', 'KMP project', 'multiplatform library'. Build cross-platform mobile apps with Kotlin Multiplatform sharing business logic, Compose Multiplatform UI, and platform-specific integrations. Do NOT use for: Androi…
Independent GitHub snapshot · Automated classification · Original documentation governs
Guide for building Kotlin Multiplatform mobile apps with shared business logic, Compose Multiplatform UI, and platform-specific integrations.
Phrases: "Kotlin Multiplatform", "KMP", "Compose Multiplatform", "shared Kotlin", "KMP module", "expect/actual", "commonMain", "KMP project", "multiplatform library"
Working KMP module with: commonMain business logic, expect/actual declarations, Compose Multiplatform screens, Gradle multi-module build configuration.
No preamble. No postamble. No explanations. No filler/hedging/transitions. Compress output — why use many token when few do trick.
8000 tokens
Need shared UI?
├── Yes → Compose Multiplatform
│ Pros: Single UI codebase, Material3 theming, navigation
│ Cons: Cannot render native components (Map, Camera, WebView)
├── Native UI per platform
│ Pros: Full native API access, platform-native UX
│ Cons: Two UI codebases, more maintenance
└── Hybrid → Compose Multiplatform + expect/actual for native components
Strategy: 80% shared UI via Compose, 20% platform composable via expect
API complexity?
├── REST + JSON → Ktor Client + kotlinx.serialization
│ Engine: OkHttp (Android), Darwin (iOS)
├── GraphQL → Apollo Kotlin (KMP support)
└── gRPC → KMP-gRPC (emerging, check compatibility)
Data model complexity?
├── Relational (SQL, joins) → SQLDelight
│ Common schema, platform drivers, Flow support
├── Key-value (settings, preferences) → multiplatform-settings
│ Wraps SharedPreferences (Android), NSUserDefaults (iOS)
├── NoSQL/Document → Realm Kotlin SDK
│ KMP-native, reactive, synchronization
└── Encrypted → SQLCipher (SQLDelight cipher) or platform Keychain/Keystore
DI framework preference?
├── Lightweight, KMP-native → Koin
│ No code gen, easy setup, reasonable runtime performance
├── Compile-time verified → Anvil (Dagger-based)
│ Works with KMP, faster than Kapt
└── Manual → Constructor injection with factory pattern
KMP project structure — Three-tier source set layout: commonMain (shared business logic, domain models, repository interfaces, Ktor client, SQLDelight schema, expect declarations), androidMain (Android-specific actual implementations, OkHttp engine, Android Sqlite driver, Context-dependent factories), iosMain (iOS-specific actual implementations, Darwin engine, NativeSqlite driver, platform factories). Additional source sets for testing: commonTest, androidUnitTest, iosTest. The shared module is consumed by Android apps as an AAR library and by iOS apps as a Kotlin/Native framework.
expect/actual pattern — Declare platform-agnostic interfaces in commonMain using expect keyword. Provide concrete implementations in platform source sets with actual. Three forms: expect fun (function), expect class (class with actual constructors), expect object (singleton), expect val (property), expect typealias (type alias for platform types). Example: expect fun generateUuid(): String with actual fun generateUuid(): String = UUID.randomUUID().toString() in Android and actual fun generateUuid(): String = platform.Foundation.NSUUID().UUIDString() in iOS. Keep expect declarations in commonMain/.../platform/ package.
Ktor client configuration — Single HttpClient declaration in commonMain with engine-agnostic setup. Use ktor-client-core for common code, ktor-client-okhttp for Android (supports HTTP/2, caching), ktor-client-darwin for iOS (uses NSURLSession, automatic cookie storage). Configure serialization with kotlinx-serialization-json using ContentNegotiation plugin. Add logging with Logging plugin. Timeout configuration: HttpTimeout plugin with requestTimeoutMillis, connectTimeoutMillis, socketTimeoutMillis.
// commonMain — Ktor client factory
val httpClient = HttpClient {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(Logging) { level = LogLevel.HEADERS }
install(HttpTimeout) { requestTimeoutMillis = 15_000 }
defaultRequest { url("https://api.example.com/") }
}
kotlinx.serialization — All data classes in commonMain use @Serializable annotation. Supports primitives, enums, sealed classes, nullable fields, default values. Custom serializers for non-standard types (Date, BigInteger). Json configuration: ignoreUnknownKeys = true for forward compatibility, coerceInputValues = true for invalid defaults, encodeDefaults = false to minimize payload. Use @SerialName for property name mapping. Polymorphic serialization for sealed class hierarchies.
Compose Multiplatform UI — All shared UI in commonMain using Jetpack Compose APIs. The org.jetbrains.compose plugin compiles Compose code for both platforms. Material3 theming with MaterialTheme — customize typography, color scheme, and shapes. Navigation options: Voyager (screen-based, type-safe), Decompose (component-based, lifecycle-aware), or custom state-driven navigation. Platform-specific composables via expect/actual for views that cannot be shared (maps, WebView, Camera). Performance: remember for expensive computations, derivedStateOf for computed state, LaunchedEffect for side effects.
Platform-specific UI integration — When Compose Multiplatform cannot render a native component, use expect composable functions. Android: wrap Android Views via AndroidView composable factory. iOS: wrap UIKit views via UIKitView composable (KMP Compose provides interop). Example: MapView, CameraPreview, WebView, NativeTextInput. Keep platform composables thin — minimal wrapper code, pass data via parameters. Platform UI code lives in androidMain/iosMain Composable files.
Testing strategy — commonTest for shared business logic: domain models, repository logic, ViewModel state. Use kotlin.test for assertions. Mock dependencies with mock libraries that support KMP (MockK, KMM- Mock). Instrumented tests on Android and iOS use platform source sets. UI testing of Compose screens uses Compose UI Test framework in commonTest. Run iOS tests on simulator via Gradle task or Xcode Test Navigator.
| Feature | commonMain | androidMain | iosMain |
|---|---|---|---|
| Business logic | Full | Thin override | Thin override |
| HTTP client | Ktor declaration | OkHttp engine | Darwin engine |
| Database | SQLDelight schema | Android driver | Native driver |
| UI (Compose) | Full | Full | Full |
| Platform APIs | expect decl | actual impl | actual impl |
| Dependency injection | Koin module decl | platform bindings | platform bindings |
libs.versions.toml catalog./gradlew allTests before committing to verify all targets compilekotlinx.datetime for cross-platform date/time handlingembedAndSignAppleFrameworkForXcode is in the build phase of your Xcode project.@Serializable class cause linker errors. Use explicit @SerialName or module-level serializers.expect/actual with generics requires @Suppress("NO_ACTUAL_FOR_EXPECT") in some cases.KMP requires building for multiple targets — Android (JVM) and iOS (Kotlin/Native). CI must handle both environments. Recommended CI matrix:
# GitHub Actions example
jobs:
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with: { distribution: 'temurin', java-version: '17' }
- name: Build Android
run: ./gradlew :shared:assembleAndroidDebug :app:assembleDebug
- name: Run common tests
run: ./gradlew :shared:allTests
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with: { distribution: 'temurin', java-version: '17' }
- name: Build iOS framework
run: |
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
./gradlew :shared:linkDebugFrameworkIosArm64
- name: Run iOS tests
run: ./gradlew :shared:iosX64Test :shared:iosSimulatorArm64Test
- name: Build Xcode project
run: |
cd iosApp
xcodebuild -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15'
The KMP shared module produces a Kotlin/Native framework consumed by Xcode:
Gradle setup: Configure framework name and target in shared/build.gradle.kts:
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "shared"
isStatic = true
export("my-exported-module") // re-export module's API
}
}
Xcode integration: Add build phase to embed framework:
cd "$SRCROOT/.." && ./gradlew :shared:embedAndSignAppleFrameworkForXcode$(SRCROOT)/../shared/build/bin/iosSimulatorArm64/debugFramework/shared.frameworkSPM/CocoaPods distribution: Publish framework as CocoaPod:
# shared.podspec
Pod::Spec.new do |spec|
spec.name = 'Shared'
spec.vendored_frameworks = 'shared.framework'
spec.platform = :ios, '16.0'
end
[versions]
kotlin = "2.0.21"
ktor = "3.0.3"
sqldelight = "2.0.2"
compose-multiplatform = "1.7.1"
koin = "3.5.6"
coroutines = "1.9.0"
datetime = "0.6.1"
[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
sqldelight-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
project/
├── shared/ # KMP shared module
│ ├── src/commonMain/
│ ├── src/androidMain/
│ ├── src/iosMain/
│ └── build.gradle.kts
├── composeApp/ # Compose Multiplatform UI module
│ ├── src/commonMain/
│ ├── src/androidMain/
│ ├── src/iosMain/
│ └── build.gradle.kts
├── app/ # Android shell app (only if not using composeApp)
│ └── src/main/
├── iosApp/ # iOS Xcode project
│ └── iosApp.xcodeproj/
└── build.gradle.kts
Keep shared module pure — no Android UI dependencies. The composeApp module depends on shared and provides the Compose UI layer. Android app module wraps composeApp; iOS app embeds the framework from composeApp or shared.
remember and derivedStateOf: Cache expensive computations. val total = remember(items) { items.sumOf { it.price } } recalculates only when items change. Use derivedStateOf for computed state that derives from other state.LaunchedEffect lifecycle: Runs in the composition's coroutine scope. Cancelled when composable leaves composition — no manual cleanup needed for coroutines. Use DisposableEffect for resources that need cleanup (listeners, observers).Modifier ordering: Order matters — Modifier.clip().size(100.dp).background(Color.Red) clips before sizing, which may not work as expected. Always clip after sizing: Modifier.size(100.dp).clip(CircleShape).background(Color.Red).remember for lambdas passed to composables: val onClick = remember { { handleClick() } }. Prevents unnecessary recomposition of child composables that receive unstable lambdas.key parameter in LazyColumn: LazyColumn { items(items, key = { it.id }) } provides stable identity — enables item animation, preserves scroll position across recomposition, and minimizes recomposition scope.contentType in lazy lists: When LazyColumn has mixed item types (headers, items, footers), set contentType parameter: items(items, contentType = { "item" }). Helps the layout engine optimize recycling.kotlin.Result zero-cost: Use Result<T> for operation outcomes instead of sealed classes in performance-critical paths — the Kotlin compiler optimizes it as a single object.IntArray, FloatArray, LongArray over List<Int>, List<Float> for numeric-heavy operations. Reduces boxing overhead 10-20x.@Immutable / @Stable annotations: Mark data classes as @Immutable (all properties final, never change) or @Stable (changes are reported to Compose). This enables Compose's compiler to skip recomposition when state hasn't changed. Without these annotations, Compose pessimistically recomposes.var in state holders: Use val with MutableState/MutableStateFlow. var customerName by remember { mutableStateOf("") } — the by delegate enables automatic recomposition. Mutating var directly (without delegation) doesn't trigger recomposition.buildList / buildMap / buildString: Allocation-efficient collection builders. buildList { addAll(items); sort() } creates a single list at the end, not intermediate copies for each operation.AsyncImage with ImageRequest.Builder for disk/memory cache, transform pipelines, and placeholder/error states.AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://example.com/image.jpg")
.crossfade(true)
.size(512, 512)
.build(),
contentDescription = "Product image",
modifier = Modifier.clip(RoundedCornerShape(8.dp))
)
HttpClient with custom engine options. OkHttp: OkHttp.config { connectionPool(ConnectionPool(5, 30, TimeUnit.SECONDS)) }. Darwin: Darwin.config { configureRequest { setAllowsCellularAccess(true) } }.kotlinx.serialization with Json { ignoreUnknownKeys = true; isLenient = true } over Gson or Moshi. KMP-native serialization is 2-3x faster than reflection-based alternatives.WebSockets { pingInterval = 30_000 } — sends ping frames every 30s to keep the connection alive and detect disconnects early.// commonMain
class OrderRepository(
private val api: OrderApi,
private val db: OrderDatabase,
) {
fun getOrders(): Flow<List<Order>> = flow {
// 1. Emit cached first
val cached = db.orderQueries.selectAll().executeAsList()
if (cached.isNotEmpty()) emit(cached.map { it.toOrder() })
// 2. Fetch fresh from network
val fresh = api.getOrders()
db.transaction {
db.orderQueries.deleteAll()
fresh.forEach { db.orderQueries.insert(it.toEntity()) }
}
// 3. Emit fresh
emit(fresh)
}
}
shared/src/commonMain/kotlin/com/app/
├── domain/ # Pure Kotlin, no framework dependencies
│ ├── model/ # Domain entities (data classes)
│ ├── repository/ # Repository interfaces
│ └── usecase/ # Business logic (single responsibility)
├── data/ # Implements domain interfaces
│ ├── remote/ # Ktor API clients
│ ├── local/ # SQLDelight DAOs
│ └── repository/ # Repository implementations
├── di/ # Koin module definitions
└── platform/ # expect declarations
└── Platform.kt
Domain layer has zero dependencies on Ktor, SQLDelight, or Compose. This makes it testable in commonTest without platform setup. Repository interfaces are in domain (OrderRepository), implementations are in data (OrderRepositoryImpl).
sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val message: String, val throwable: Throwable?) : UiState<Nothing>
}
@Composable
fun <T> ContentView(
state: UiState<T>,
onRetry: () -> Unit,
content: @Composable (T) -> Unit
) {
when (state) {
is UiState.Loading -> ShimmerLoading()
is UiState.Success -> content(state.data)
is UiState.Error -> ErrorView(state.message, onRetry)
}
}
java.io.File in commonMain breaks compilation. Use expect/actual or interfaces for platform types.expect declaration needs actual on every platform — increases maintenance. Prefer interfaces with platform DI.Dispatchers.Main.immediate with withContext for UI updates. Never use runBlocking on main thread in iOS.isStatic = true and set embedAndSignAppleFrameworkForXcode to use release builds in production.kotlin.test vs platform test runners: commonTest uses kotlin.test assertions, which differ from JUnit 5 and XCTest. Teams must learn KMP test patterns.expect composable wrappers and keep them thin.@ThreadLocal on iOS objects: iOS object initializers run on arbitrary threads. Use @ThreadLocal annotation or AtomicReference for mutable state accessed from multiple threads.// build.gradle.kts (shared module)
plugins {
id("org.jetbrains.kotlin.multiplatform") version "2.0.21"
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"
id("org.jetbrains.compose") version "1.7.1"
id("app.cash.sqldelight") version "2.0.2"
}
kotlin {
androidTarget { compilations.all { kotlinOptions { jvmTarget = "17" } } }
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework { baseName = "shared"; isStatic = true }
}
sourceSets {
commonMain.dependencies {
implementation("io.ktor:ktor-client-core:3.0.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("app.cash.sqldelight:runtime:2.0.2")
implementation(compose.runtime); implementation(compose.foundation)
implementation(compose.material3); implementation(compose.ui)
}
androidMain.dependencies {
implementation("io.ktor:ktor-client-okhttp:3.0.3")
implementation("app.cash.sqldelight:android-driver:2.0.2")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:3.0.3")
implementation("app.cash.sqldelight:native-driver:2.0.2")
}
}
}
Hand off to platform-specific iOS or Android skills when expect/actual implementations need deep platform API knowledge.
` interface EventObserver { onEvent(event: T): Promise; }
class EventBus { private observers: Set<EventObserver> = new Set(); subscribe(observer: EventObserver): void { this.observers.add(observer); } unsubscribe(observer: EventObserver): void { this.observers.delete(observer); } async emit(event: T): Promise { const results = Array.from(this.observers).map(o => o.onEvent(event)); await Promise.allSettled(results); } } `
config: defaults: timeout: 30s retryCount: 3 overrides: production: timeout: 60s retryCount: 5 development: timeout: 300s retryCount: 1
| Metric | Threshold | Severity | Action |
|---|---|---|---|
| Error rate | > 1% over 5min | Critical | Page on-call |
| p99 latency | > 2s over 5min | Warning | Investigate |
| Throughput drop | > 50% over 1min | Critical | Check upstream |
| Queue depth | > 1000 over 1min | Warning | Scale consumers |
| Disk usage | > 85% | Warning | Clean or expand |
| Memory usage | > 90% heap | Critical | Restart or scale |
| Anti-Pattern | Symptom | Root Cause | Solution |
|---|---|---|---|
| Premature optimization | Complex code for no measured benefit | Guessing instead of profiling | Measure first, optimize based on data |
| Copy-paste reuse | Duplicate code across codebase | Lack of abstraction | Extract shared logic into libraries |
| Gold-plating | Features with no current requirement | Over-engineering | YAGNI — build what's needed now |
| Magical thinking | Assumptions without validation | Skipping error handling | Handle all failure modes explicitly |
Cache hierarchy: L1 (in-memory local) → L2 (distributed Redis/Memcached) → L3 (CDN/Edge). Cache invalidation: TTL-based (simple, stale), event-based (complex, fresh), write-through (consistent, higher write latency), write-behind (fast writes, eventual consistency).