Kotlin Multiplatform in Production: What Actually Works
Lessons from shipping KMP in production — what to share, what to keep native, and the architectural patterns that survive real-world mobile development.
Kotlin Multiplatform in Production: What Actually Works
After shipping multiple KMP projects to production, I've learned that the technology works — but the architecture decisions around it determine whether your team moves faster or drowns in abstraction.
This is not a "getting started with KMP" guide. This is about what happens after you've set up your first shared module and need to make it survive real product requirements.
What to Share, What to Keep Native
The biggest mistake teams make with KMP is trying to share too much. Here's the framework I use:
Share: Business Logic and Data
// shared/src/commonMain/kotlin/com/app/domain/TransactionValidator.kt
class TransactionValidator(
private val currencyConverter: CurrencyConverter,
private val complianceRules: ComplianceRules,
) {
fun validate(transaction: Transaction): ValidationResult {
val checks = listOf(
checkAmount(transaction),
checkCurrency(transaction),
checkCompliance(transaction),
checkDailyLimit(transaction),
)
val failures = checks.filterIsInstance<ValidationCheck.Failed>()
return if (failures.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(failures.map { it.reason })
}
}
}This is pure business logic. No platform dependencies. No UI concerns. It should be shared because:
- It must behave identically on Android and iOS
- It's testable without any platform-specific setup
- Bugs here have financial implications — one implementation means one place to fix
Don't Share: UI and Platform Integration
If you're sharing UI code through KMP (Compose Multiplatform for production apps with platform-specific UX requirements), think twice. Each platform has different navigation patterns, accessibility APIs, and user expectations. Shared UI often means "mediocre everywhere."
Keep native:
- Navigation — each platform has its own patterns
- UI components — Compose on Android, SwiftUI on iOS
- Platform APIs — camera, biometrics, push notifications
- Animations — platform-native feels better
The Expect/Actual Pattern in Practice
expect/actual is KMP's mechanism for platform-specific implementations. Here's how I structure it for real production code:
// shared/src/commonMain/kotlin/com/app/core/crypto/HashProvider.kt
expect class HashProvider() {
fun sha256(input: ByteArray): ByteArray
fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray
}// shared/src/androidMain/kotlin/com/app/core/crypto/HashProvider.kt
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
actual class HashProvider {
actual fun sha256(input: ByteArray): ByteArray {
return MessageDigest.getInstance("SHA-256").digest(input)
}
actual fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key, "HmacSHA256"))
return mac.doFinal(data)
}
}// shared/src/iosMain/kotlin/com/app/core/crypto/HashProvider.kt
import platform.Foundation.*
import kotlinx.cinterop.*
actual class HashProvider {
actual fun sha256(input: ByteArray): ByteArray {
// CommonCrypto implementation via cinterop
// ...
}
actual fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray {
// CommonCrypto HMAC implementation
// ...
}
}The rule: expect/actual should be used for infrastructure (crypto, storage, networking), not for business logic. If you find yourself writing expect/actual for a use case, your abstraction boundary is wrong.
Network Layer Architecture
The network layer is where KMP shines. Ktor gives you a unified HTTP client with platform-specific engines:
// shared/src/commonMain/kotlin/com/app/network/ApiClient.kt
class ApiClient(
private val httpClient: HttpClient,
private val tokenProvider: TokenProvider,
) {
suspend fun getTransactions(
accountId: String,
page: Int = 0,
): ApiResult<List<Transaction>> {
return safeApiCall {
httpClient.get("accounts/$accountId/transactions") {
parameter("page", page)
parameter("limit", 20)
bearerAuth(tokenProvider.getAccessToken())
}.body()
}
}
}
// Reusable error handling wrapper
private suspend inline fun <reified T> safeApiCall(
crossinline block: suspend () -> T
): ApiResult<T> {
return try {
ApiResult.Success(block())
} catch (e: ClientRequestException) {
ApiResult.Error(mapHttpError(e.response.status.value))
} catch (e: Exception) {
ApiResult.Error(ApiError.Network(e.message ?: "Unknown error"))
}
}Both platforms use the same API client, same error handling, same serialization. The only difference is the engine:
// Android: OkHttp engine
val httpClient = HttpClient(OkHttp) {
install(ContentNegotiation) { json() }
install(Logging) { level = LogLevel.HEADERS }
}
// iOS: Darwin engine
val httpClient = HttpClient(Darwin) {
install(ContentNegotiation) { json() }
install(Logging) { level = LogLevel.HEADERS }
}Data Persistence with SQLDelight
SQLDelight is the go-to for shared persistence in KMP. The key architectural insight: your database is a shared implementation detail, not a shared API.
-- shared/src/commonMain/sqldelight/com/app/db/Transaction.sq
CREATE TABLE transaction_entity (
id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT NOT NULL,
timestamp INTEGER NOT NULL,
status TEXT NOT NULL
);
getByAccountId:
SELECT * FROM transaction_entity
WHERE account_id = ?
ORDER BY timestamp DESC
LIMIT ?;
insert:
INSERT OR REPLACE INTO transaction_entity
VALUES (?, ?, ?, ?, ?, ?);SQLDelight generates type-safe Kotlin code from SQL. The queries are compiled and verified at build time — if your SQL is wrong, the build fails.
Testing Strategy
KMP testing requires a layered approach:
- Common tests — run on JVM, test shared business logic
- Platform tests — test
expect/actualimplementations - Integration tests — test the shared module from each platform's perspective
// shared/src/commonTest/kotlin/com/app/domain/TransactionValidatorTest.kt
class TransactionValidatorTest {
private val validator = TransactionValidator(
currencyConverter = FakeCurrencyConverter(),
complianceRules = FakeComplianceRules(),
)
@Test
fun validTransaction_returnsValid() {
val transaction = Transaction(
amount = 100.0,
currency = "USD",
recipientId = "user-123",
)
val result = validator.validate(transaction)
assertEquals(ValidationResult.Valid, result)
}
@Test
fun exceedsDailyLimit_returnsInvalid() {
val transaction = Transaction(
amount = 1_000_000.0,
currency = "USD",
recipientId = "user-123",
)
val result = validator.validate(transaction)
assertIs<ValidationResult.Invalid>(result)
assertTrue(result.reasons.any { it.contains("daily limit") })
}
}Key Takeaways
- Share business logic and data, not UI — the ROI is in eliminating duplicate domain code
- Use expect/actual for infrastructure — crypto, storage, platform APIs
- Ktor + SQLDelight are production-ready — I've shipped them in fintech apps
- Test at the common level first — most of your tests should run without platform dependencies
- Don't try to share everything — 60% code sharing is a win; 90% usually means poor UX on both platforms
KMP is not a silver bullet, but it's the most pragmatic approach to code sharing I've used. The key is knowing where the boundaries should be.
Evaluating KMP for your team? Let's discuss your architecture — I help teams make the right technology decisions.
Enjoyed this article?
I write about Android architecture, Kotlin, and mobile systems engineering. Let's connect.