← Back to Blog

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.

AG Mobile Labs10 min read
Kotlin MultiplatformKMPArchitectureCross-Platform

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

⚠️Warning

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
        // ...
    }
}
ℹ️Info

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:

  1. Common tests — run on JVM, test shared business logic
  2. Platform tests — test expect/actual implementations
  3. 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

  1. Share business logic and data, not UI — the ROI is in eliminating duplicate domain code
  2. Use expect/actual for infrastructure — crypto, storage, platform APIs
  3. Ktor + SQLDelight are production-ready — I've shipped them in fintech apps
  4. Test at the common level first — most of your tests should run without platform dependencies
  5. 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.