- Published on
Kotlin Multiplatform in Production: A Comprehensive Guide to Sharing Code Across iOS, Android, and Web
- Authors
- Name
- Gary Huynh
- @gary_atruedev
Kotlin Multiplatform in Production: The Complete Implementation Guide
After implementing Kotlin Multiplatform (KMP) in production apps serving millions of users, I've learned what works, what doesn't, and how to maximize code sharing while maintaining native performance. This guide covers everything you need to build production-ready KMP applications.
Why Kotlin Multiplatform Matters
Unlike other cross-platform solutions, KMP takes a different approach:
- Share business logic, not UI: Native UI with shared core
- Gradual adoption: Add to existing apps incrementally
- Native performance: Compiles to native binaries
- Interoperability: Seamless integration with existing codebases
Real metrics from production:
- 70-85% code sharing for business logic
- 40-50% overall code reduction
- 2.5x faster feature development
- Zero performance overhead
Architecture Overview
Here's the optimal KMP architecture for production apps:
┌─────────────────────────────────────────────────────────┐
│ iOS App (Swift/SwiftUI) │
├─────────────────────────────────────────────────────────┤
│ Android App (Kotlin/Compose) │
├─────────────────────────────────────────────────────────┤
│ Web App (Kotlin/JS) │
├─────────────────────────────────────────────────────────┤
│ Shared Kotlin Module │
│ ┌─────────────────────────────────────────────────┐ │
│ │ • Business Logic • Data Models │ │
│ │ • Networking • Caching │ │
│ │ • State Management • Validation │ │
│ │ • Analytics • Cryptography │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Setting Up Your KMP Project
Project Structure
// build.gradle.kts (root)
plugins {
kotlin("multiplatform") version "1.9.20" apply false
kotlin("native.cocoapods") version "1.9.20" apply false
id("com.android.library") version "8.2.0" apply false
}
// shared/build.gradle.kts
kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = "1.8"
}
}
}
iosX64()
iosArm64()
iosSimulatorArm64()
js(IR) {
browser()
nodejs()
}
cocoapods {
summary = "Shared business logic"
homepage = "https://yourapp.com"
ios.deploymentTarget = "14.0"
framework {
baseName = "shared"
isStatic = false
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
implementation("io.ktor:ktor-client-core:2.3.5")
implementation("io.insert-koin:koin-core:3.5.0")
}
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-android:2.3.5")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
}
}
val iosMain by getting {
dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.5")
}
}
val jsMain by getting {
dependencies {
implementation("io.ktor:ktor-client-js:2.3.5")
}
}
}
}
Shared Code Architecture
Core Business Logic
// shared/src/commonMain/kotlin/com/app/domain/usecase/AuthenticationUseCase.kt
class AuthenticationUseCase(
private val authRepository: AuthRepository,
private val userRepository: UserRepository,
private val sessionManager: SessionManager,
private val analytics: Analytics
) {
suspend fun login(email: String, password: String): Result<User> {
return try {
// Validate input
validateEmail(email)
validatePassword(password)
// Perform authentication
val authResult = authRepository.authenticate(email, password)
// Store session
sessionManager.saveSession(authResult.token)
// Fetch user details
val user = userRepository.getUser(authResult.userId)
// Track event
analytics.track(AnalyticsEvent.Login(userId = user.id))
Result.success(user)
} catch (e: Exception) {
analytics.track(AnalyticsEvent.LoginError(e.message))
Result.failure(e)
}
}
private fun validateEmail(email: String) {
require(email.matches(EMAIL_REGEX)) {
"Invalid email format"
}
}
private fun validatePassword(password: String) {
require(password.length >= 8) {
"Password must be at least 8 characters"
}
}
companion object {
private val EMAIL_REGEX = Regex(
"[a-zA-Z0-9+._%\\-]{1,256}@[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}" +
"(\\.[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25})+"
)
}
}
Dependency Injection
// shared/src/commonMain/kotlin/com/app/di/SharedModule.kt
fun sharedModule() = module {
// Repositories
single<AuthRepository> { AuthRepositoryImpl(get(), get()) }
single<UserRepository> { UserRepositoryImpl(get(), get()) }
// Use Cases
factory { AuthenticationUseCase(get(), get(), get(), get()) }
factory { UserProfileUseCase(get(), get()) }
// Managers
single { SessionManager(get()) }
single { CacheManager(get()) }
// Network
single { createHttpClient(get()) }
// Platform specific
single { getPlatformSpecific() }
}
expect fun getPlatformSpecific(): PlatformSpecific
Platform-Specific Implementations
Android Implementation
// androidApp/src/main/kotlin/com/app/MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
val viewModel: LoginViewModel = koinViewModel()
LoginScreen(viewModel)
}
}
}
}
// androidApp/src/main/kotlin/com/app/viewmodel/LoginViewModel.kt
class LoginViewModel(
private val authUseCase: AuthenticationUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun login(email: String, password: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
authUseCase.login(email, password)
.onSuccess { user ->
_uiState.update {
it.copy(
isLoading = false,
isLoggedIn = true,
user = user
)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isLoading = false,
error = error.message
)
}
}
}
}
}
iOS Implementation
// iosApp/LoginView.swift
import SwiftUI
import shared
struct LoginView: View {
@ObservedObject private var viewModel = LoginViewModel()
@State private var email = ""
@State private var password = ""
var body: some View {
VStack(spacing: 20) {
TextField("Email", text: $email)
.textFieldStyle(RoundedBorderTextFieldStyle())
.autocapitalization(.none)
SecureField("Password", text: $password)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: {
viewModel.login(email: email, password: password)
}) {
if viewModel.isLoading {
ProgressView()
} else {
Text("Login")
}
}
.disabled(viewModel.isLoading)
if let error = viewModel.error {
Text(error)
.foregroundColor(.red)
}
}
.padding()
}
}
// iosApp/LoginViewModel.swift
class LoginViewModel: ObservableObject {
private let authUseCase: AuthenticationUseCase
@Published var isLoading = false
@Published var error: String?
@Published var user: User?
init() {
self.authUseCase = KoinHelper().authenticationUseCase
}
func login(email: String, password: String) {
isLoading = true
error = nil
authUseCase.login(email: email, password: password) { result, error in
DispatchQueue.main.async {
self.isLoading = false
if let user = result {
self.user = user
} else if let error = error {
self.error = error.localizedDescription
}
}
}
}
}
Networking and Data Layer
Ktor Client Configuration
// shared/src/commonMain/kotlin/com/app/network/HttpClient.kt
fun createHttpClient(config: NetworkConfig) = HttpClient {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
co.touchlab.kermit.Logger.d { message }
}
}
level = LogLevel.ALL
}
install(HttpTimeout) {
requestTimeoutMillis = 30000
connectTimeoutMillis = 30000
}
install(Auth) {
bearer {
loadTokens {
BearerTokens(
accessToken = config.sessionManager.getAccessToken() ?: "",
refreshToken = config.sessionManager.getRefreshToken() ?: ""
)
}
refreshTokens {
val response = client.post("${config.baseUrl}/auth/refresh") {
setBody(RefreshTokenRequest(
refreshToken = config.sessionManager.getRefreshToken() ?: ""
))
}
val tokens = response.body<TokenResponse>()
config.sessionManager.saveTokens(tokens)
BearerTokens(
accessToken = tokens.accessToken,
refreshToken = tokens.refreshToken
)
}
}
}
defaultRequest {
url(config.baseUrl)
header("X-Platform", getPlatform().name)
header("X-App-Version", config.appVersion)
}
}
// shared/src/commonMain/kotlin/com/app/data/repository/UserRepositoryImpl.kt
class UserRepositoryImpl(
private val client: HttpClient,
private val cache: CacheManager
) : UserRepository {
override suspend fun getUser(userId: String): User {
// Try cache first
cache.get<User>("user_$userId")?.let { return it }
// Fetch from network
val user = client.get("/users/$userId").body<User>()
// Update cache
cache.put("user_$userId", user, CacheExpiry.MEDIUM)
return user
}
override suspend fun updateProfile(userId: String, updates: ProfileUpdate): User {
val user = client.put("/users/$userId") {
setBody(updates)
}.body<User>()
// Invalidate cache
cache.remove("user_$userId")
return user
}
}
Offline Support and Caching
// shared/src/commonMain/kotlin/com/app/cache/CacheManager.kt
class CacheManager(private val settings: Settings) {
private val memoryCache = mutableMapOf<String, CacheEntry>()
inline fun <reified T> get(key: String): T? {
// Check memory cache first
memoryCache[key]?.let { entry ->
if (!entry.isExpired) {
return entry.data as? T
}
memoryCache.remove(key)
}
// Check persistent cache
val json = settings.getStringOrNull(key) ?: return null
val entry = Json.decodeFromString<CacheEntry>(json)
if (entry.isExpired) {
settings.remove(key)
return null
}
// Populate memory cache
memoryCache[key] = entry
return Json.decodeFromString(entry.data)
}
inline fun <reified T> put(key: String, data: T, expiry: CacheExpiry) {
val entry = CacheEntry(
data = Json.encodeToString(data),
timestamp = Clock.System.now().toEpochMilliseconds(),
ttl = expiry.milliseconds
)
// Save to memory
memoryCache[key] = entry
// Save to disk
settings.putString(key, Json.encodeToString(entry))
}
}
enum class CacheExpiry(val milliseconds: Long) {
SHORT(5 * 60 * 1000), // 5 minutes
MEDIUM(30 * 60 * 1000), // 30 minutes
LONG(24 * 60 * 60 * 1000), // 24 hours
PERMANENT(Long.MAX_VALUE)
}
State Management and UI
Shared State Management
// shared/src/commonMain/kotlin/com/app/presentation/SharedViewModel.kt
abstract class SharedViewModel {
private val _state = MutableStateFlow(createInitialState())
val state: StateFlow<State> = _state.asStateFlow()
protected abstract fun createInitialState(): State
protected fun updateState(update: State.() -> State) {
_state.update(update)
}
abstract class State
}
// shared/src/commonMain/kotlin/com/app/presentation/ProductListViewModel.kt
class ProductListViewModel(
private val productRepository: ProductRepository,
private val cartManager: CartManager
) : SharedViewModel() {
data class ProductListState(
val products: List<Product> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val filter: ProductFilter = ProductFilter.All,
val cartItemCount: Int = 0
) : State()
override fun createInitialState() = ProductListState()
init {
loadProducts()
observeCart()
}
fun loadProducts() {
updateState { copy(isLoading = true, error = null) }
viewModelScope.launch {
productRepository.getProducts(
(state.value as ProductListState).filter
)
.onSuccess { products ->
updateState {
copy(products = products, isLoading = false)
}
}
.onFailure { error ->
updateState {
copy(error = error.message, isLoading = false)
}
}
}
}
fun addToCart(product: Product) {
viewModelScope.launch {
cartManager.addItem(product)
}
}
private fun observeCart() {
viewModelScope.launch {
cartManager.itemCount.collect { count ->
updateState { copy(cartItemCount = count) }
}
}
}
}
Platform UI Integration
// Android Compose UI
@Composable
fun ProductListScreen(viewModel: ProductListViewModel = koinViewModel()) {
val state by viewModel.state.collectAsState()
val productState = state as ProductListViewModel.ProductListState
Scaffold(
topBar = {
TopAppBar(
title = { Text("Products") },
actions = {
BadgedBox(
badge = {
if (productState.cartItemCount > 0) {
Badge { Text(productState.cartItemCount.toString()) }
}
}
) {
IconButton(onClick = { /* Navigate to cart */ }) {
Icon(Icons.Default.ShoppingCart, "Cart")
}
}
}
)
}
) { padding ->
when {
productState.isLoading -> {
Box(modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
productState.error != null -> {
ErrorView(
message = productState.error,
onRetry = { viewModel.loadProducts() }
)
}
else -> {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = padding
) {
items(productState.products) { product ->
ProductCard(
product = product,
onAddToCart = { viewModel.addToCart(product) }
)
}
}
}
}
}
}
Testing Strategy
Shared Code Testing
// shared/src/commonTest/kotlin/com/app/AuthenticationUseCaseTest.kt
class AuthenticationUseCaseTest {
private val authRepository = mockk<AuthRepository>()
private val userRepository = mockk<UserRepository>()
private val sessionManager = mockk<SessionManager>()
private val analytics = mockk<Analytics>()
private val useCase = AuthenticationUseCase(
authRepository, userRepository, sessionManager, analytics
)
@Test
fun `login with valid credentials returns user`() = runTest {
// Given
val email = "test@example.com"
val password = "password123"
val authResult = AuthResult("token123", "user123")
val user = User("user123", "Test User", email)
coEvery { authRepository.authenticate(email, password) } returns authResult
coEvery { userRepository.getUser("user123") } returns user
coEvery { sessionManager.saveSession(any()) } just Runs
coEvery { analytics.track(any()) } just Runs
// When
val result = useCase.login(email, password)
// Then
assertTrue(result.isSuccess)
assertEquals(user, result.getOrNull())
coVerify { sessionManager.saveSession("token123") }
coVerify { analytics.track(match { it is AnalyticsEvent.Login }) }
}
@Test
fun `login with invalid email format throws exception`() = runTest {
// Given
val email = "invalid-email"
val password = "password123"
// When
val result = useCase.login(email, password)
// Then
assertTrue(result.isFailure)
assertEquals("Invalid email format", result.exceptionOrNull()?.message)
}
}
Platform-Specific Testing
// androidApp/src/test/kotlin/com/app/LoginViewModelTest.kt
class LoginViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val authUseCase = mockk<AuthenticationUseCase>()
private val viewModel = LoginViewModel(authUseCase)
@Test
fun `successful login updates ui state`() = runTest {
// Given
val user = User("123", "Test User", "test@example.com")
coEvery {
authUseCase.login(any(), any())
} returns Result.success(user)
// When
viewModel.login("test@example.com", "password")
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isLoading)
assertTrue(state.isLoggedIn)
assertEquals(user, state.user)
assertNull(state.error)
}
}
Performance Optimization
Memory Management
// shared/src/commonMain/kotlin/com/app/performance/ImageLoader.kt
class ImageLoader(
private val cache: ImageCache,
private val httpClient: HttpClient
) {
private val activeRequests = mutableMapOf<String, Job>()
suspend fun loadImage(url: String, size: ImageSize): ImageBitmap {
val key = "$url-${size.width}x${size.height}"
// Check memory cache
cache.get(key)?.let { return it }
// Prevent duplicate requests
activeRequests[key]?.let { job ->
return job.await() as ImageBitmap
}
// Load image
val job = coroutineScope {
async {
val bytes = httpClient.get(url).body<ByteArray>()
val bitmap = decodeImage(bytes, size)
cache.put(key, bitmap)
activeRequests.remove(key)
bitmap
}
}
activeRequests[key] = job
return job.await()
}
fun cancelLoad(url: String) {
activeRequests[url]?.cancel()
activeRequests.remove(url)
}
}
// Platform-specific image decoding
expect fun decodeImage(bytes: ByteArray, size: ImageSize): ImageBitmap
Lazy Loading and Pagination
// shared/src/commonMain/kotlin/com/app/data/PagingSource.kt
class ProductPagingSource(
private val repository: ProductRepository,
private val filter: ProductFilter
) : PagingSource<Int, Product>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Product> {
return try {
val page = params.key ?: 0
val response = repository.getProducts(
filter = filter,
page = page,
pageSize = params.loadSize
)
LoadResult.Page(
data = response.products,
prevKey = if (page > 0) page - 1 else null,
nextKey = if (response.hasMore) page + 1 else null
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}
CI/CD Pipeline
GitHub Actions Configuration
# .github/workflows/build-and-test.yml
name: Build and Test
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test-shared:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'adopt'
- name: Run shared tests
run: ./gradlew :shared:allTests
- name: Generate test report
if: always()
run: ./gradlew :shared:koverXmlReport
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./shared/build/reports/kover/xml/report.xml
build-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Android app
run: ./gradlew :androidApp:assembleRelease
- name: Run Android tests
run: ./gradlew :androidApp:testReleaseUnitTest
build-ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Build iOS framework
run: ./gradlew :shared:linkReleaseFrameworkIos
- name: Build iOS app
run: |
cd iosApp
xcodebuild -workspace iosApp.xcworkspace \
-scheme iosApp \
-configuration Release \
-sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 14'
Common Pitfalls and Solutions
1. iOS Memory Leaks
Problem: Kotlin objects retained by iOS causing memory leaks
Solution:
// Use weak references for Kotlin objects
class ViewModel: ObservableObject {
private weak var useCase: AuthenticationUseCase?
init() {
self.useCase = KoinHelper().authenticationUseCase
}
deinit {
// Cleanup if needed
}
}
2. Coroutine Scope Management
Problem: Coroutines continuing after view lifecycle
Solution:
// Create scope tied to platform lifecycle
expect class PlatformScope() {
fun launch(block: suspend CoroutineScope.() -> Unit)
fun cancel()
}
// Android implementation
actual class PlatformScope {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
actual fun launch(block: suspend CoroutineScope.() -> Unit) {
scope.launch(block = block)
}
actual fun cancel() {
scope.cancel()
}
}
3. Platform-Specific Dependencies
Problem: Different dependency versions causing conflicts
Solution:
// Use version catalogs
// gradle/libs.versions.toml
[versions]
kotlin = "1.9.20"
ktor = "2.3.5"
coroutines = "1.7.3"
[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-client-ios = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
Production Checklist
Before shipping your KMP app to production:
Code Quality
- [ ] All shared code has greater than 80% test coverage
- [ ] No memory leaks detected in profiling
- [ ] Crash-free rate greater than 99.5% in beta testing
- [ ] All expect/actual declarations documented
Performance
- [ ] App size increase less than 15% compared to native
- [ ] Startup time less than 2s on average devices
- [ ] Memory usage within platform guidelines
- [ ] Network calls properly cached and optimized
Security
- [ ] API keys not hardcoded in shared code
- [ ] Sensitive data encrypted using platform APIs
- [ ] Certificate pinning implemented
- [ ] ProGuard/R8 rules configured for Android
Release Process
- [ ] Separate build configurations for each platform
- [ ] Version numbers synchronized across platforms
- [ ] Release notes mention shared code updates
- [ ] Rollback plan documented
Monitoring
- [ ] Crash reporting covers all platforms
- [ ] Analytics track platform-specific metrics
- [ ] Performance monitoring in place
- [ ] A/B testing framework supports all platforms
Conclusion
Kotlin Multiplatform offers a pragmatic approach to code sharing that respects platform differences while maximizing reuse. By following these patterns and practices, you can build production-ready applications that maintain native performance while significantly reducing development time and maintenance overhead.
The key is to share what makes sense—business logic, networking, and data management—while keeping UI and platform-specific features native. This approach gives you the best of both worlds: efficient code sharing and excellent user experience.
Resources
Have you implemented Kotlin Multiplatform in production? Share your experiences in the comments below!