Published on

Kotlin Multiplatform in Production: A Comprehensive Guide to Sharing Code Across iOS, Android, and Web

Authors

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!