Kotlin - Multiplatform

Try it: Build cross-platform applications with shared business logic and platform-specific implementations.

What is Kotlin Multiplatform?

Kotlin Multiplatform (KMP) allows you to share code between different platforms while retaining the flexibility to write platform-specific code when needed. Write once, run everywhere with native performance.

Project Structure

// build.gradle.kts (Project level)
plugins {
    kotlin("multiplatform") version "1.9.20"
    kotlin("native.cocoapods") version "1.9.20"
    id("com.android.library")
}

kotlin {
    // JVM Target
    jvm {
        jvmToolchain(11)
        withJava()
    }
    
    // Android Target
    android {
        compileSdk = 34
    }
    
    // iOS Targets
    ios()
    iosSimulatorArm64()
    
    // Web Target
    js(IR) {
        browser {
            testTask {
                useKarma {
                    useChromeHeadless()
                }
            }
        }
        nodejs()
    }
    
    // Native Targets
    linuxX64()
    mingwX64()
    macosX64()
    
    sourceSets {
        // Common code
        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("org.jetbrains.kotlinx:kotlinx-datetime:0.4.1")
            }
        }
        
        val commonTest by getting {
            dependencies {
                implementation(kotlin("test"))
            }
        }
        
        // JVM specific
        val jvmMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-okhttp:2.3.5")
            }
        }
        
        // Android specific
        val androidMain by getting {
            dependencies {
                implementation("androidx.core:core-ktx:1.12.0")
            }
        }
        
        // iOS specific
        val iosMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-darwin:2.3.5")
            }
        }
        
        // Web specific
        val jsMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-js:2.3.5")
            }
        }
    }
}

Common Code

// commonMain/kotlin/User.kt
import kotlinx.serialization.Serializable
import kotlinx.datetime.Instant

@Serializable
data class User(
    val id: String,
    val name: String,
    val email: String,
    val createdAt: Instant
)

// commonMain/kotlin/UserRepository.kt
interface UserRepository {
    suspend fun getUser(id: String): User?
    suspend fun saveUser(user: User): User
    suspend fun getAllUsers(): List<User>
}

// commonMain/kotlin/UserService.kt
class UserService(private val repository: UserRepository) {
    
    suspend fun createUser(name: String, email: String): Result<User> {
        return try {
            if (!isValidEmail(email)) {
                Result.failure(IllegalArgumentException("Invalid email format"))
            } else {
                val user = User(
                    id = generateId(),
                    name = name,
                    email = email,
                    createdAt = kotlinx.datetime.Clock.System.now()
                )
                val savedUser = repository.saveUser(user)
                Result.success(savedUser)
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    
    suspend fun getUserProfile(id: String): Result<UserProfile> {
        return try {
            val user = repository.getUser(id)
                ?: return Result.failure(NoSuchElementException("User not found"))
            
            val profile = UserProfile(
                user = user,
                lastActive = kotlinx.datetime.Clock.System.now(),
                isOnline = checkOnlineStatus(user.id)
            )
            Result.success(profile)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    
    private fun isValidEmail(email: String): Boolean {
        return email.contains("@") && email.contains(".")
    }
    
    private fun generateId(): String = "user_${kotlinx.datetime.Clock.System.now().epochSeconds}"
    
    private suspend fun checkOnlineStatus(userId: String): Boolean {
        // Platform-specific implementation needed
        return getPlatform().checkUserOnline(userId)
    }
}

data class UserProfile(
    val user: User,
    val lastActive: Instant,
    val isOnline: Boolean
)

// Platform interface
expect class Platform() {
    val name: String
    suspend fun checkUserOnline(userId: String): Boolean
}

expect fun getPlatform(): Platform

Platform-Specific Implementations

// jvmMain/kotlin/Platform.jvm.kt
import java.net.InetAddress

actual class Platform actual constructor() {
    actual val name: String = "JVM ${System.getProperty("java.version")}"
    
    actual suspend fun checkUserOnline(userId: String): Boolean {
        // JVM-specific implementation using network checks
        return try {
            InetAddress.getByName("api.example.com").isReachable(1000)
        } catch (e: Exception) {
            false
        }
    }
}

actual fun getPlatform(): Platform = Platform()

// androidMain/kotlin/Platform.android.kt
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities

actual class Platform actual constructor() {
    actual val name: String = "Android ${android.os.Build.VERSION.SDK_INT}"
    
    actual suspend fun checkUserOnline(userId: String): Boolean {
        // Android-specific implementation using ConnectivityManager
        val context = AndroidContext.getApplicationContext()
        val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        val network = connectivityManager.activeNetwork ?: return false
        val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
        
        return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
    }
}

actual fun getPlatform(): Platform = Platform()

// iosMain/kotlin/Platform.ios.kt
import platform.Foundation.NSHost
import platform.Foundation.NSString
import platform.Foundation.NSURL

actual class Platform actual constructor() {
    actual val name: String = "iOS ${platform.UIKit.UIDevice.currentDevice.systemVersion}"
    
    actual suspend fun checkUserOnline(userId: String): Boolean {
        // iOS-specific implementation using NSHost
        return try {
            val host = NSHost.hostWithName("api.example.com")
            host != null
        } catch (e: Exception) {
            false
        }
    }
}

actual fun getPlatform(): Platform = Platform()

// jsMain/kotlin/Platform.js.kt
actual class Platform actual constructor() {
    actual val name: String = "Web ${js("navigator.userAgent")}"
    
    actual suspend fun checkUserOnline(userId: String): Boolean {
        // Web-specific implementation using navigator.onLine
        return js("navigator.onLine") as Boolean
    }
}

actual fun getPlatform(): Platform = Platform()

HTTP Client with Multiplatform

// commonMain/kotlin/ApiClient.kt
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json

class ApiClient {
    private val client = HttpClient {
        install(ContentNegotiation) {
            json(Json {
                prettyPrint = true
                isLenient = true
                ignoreUnknownKeys = true
            })
        }
    }
    
    suspend fun getUser(id: String): User? {
        return try {
            client.get("https://api.example.com/users/$id").body()
        } catch (e: Exception) {
            println("Failed to fetch user: ${e.message}")
            null
        }
    }
    
    suspend fun createUser(user: User): User? {
        return try {
            client.post("https://api.example.com/users") {
                contentType(ContentType.Application.Json)
                setBody(user)
            }.body()
        } catch (e: Exception) {
            println("Failed to create user: ${e.message}")
            null
        }
    }
    
    suspend fun getAllUsers(): List<User> {
        return try {
            client.get("https://api.example.com/users").body()
        } catch (e: Exception) {
            println("Failed to fetch users: ${e.message}")
            emptyList()
        }
    }
    
    fun close() {
        client.close()
    }
}

// commonMain/kotlin/ApiUserRepository.kt
class ApiUserRepository(private val apiClient: ApiClient) : UserRepository {
    
    override suspend fun getUser(id: String): User? {
        return apiClient.getUser(id)
    }
    
    override suspend fun saveUser(user: User): User {
        return apiClient.createUser(user) ?: throw RuntimeException("Failed to save user")
    }
    
    override suspend fun getAllUsers(): List<User> {
        return apiClient.getAllUsers()
    }
}

Database Storage (Platform-Specific)

// commonMain/kotlin/DatabaseRepository.kt
expect class DatabaseUserRepository() : UserRepository {
    override suspend fun getUser(id: String): User?
    override suspend fun saveUser(user: User): User
    override suspend fun getAllUsers(): List<User>
}

// jvmMain/kotlin/DatabaseRepository.jvm.kt
import java.sql.DriverManager
import java.sql.Connection
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json

actual class DatabaseUserRepository actual constructor() : UserRepository {
    private val connection: Connection by lazy {
        DriverManager.getConnection("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")
            .also { conn ->
                conn.createStatement().execute("""
                    CREATE TABLE IF NOT EXISTS users (
                        id VARCHAR(255) PRIMARY KEY,
                        name VARCHAR(255) NOT NULL,
                        email VARCHAR(255) NOT NULL,
                        created_at BIGINT NOT NULL
                    )
                """)
            }
    }
    
    actual override suspend fun getUser(id: String): User? {
        val stmt = connection.prepareStatement("SELECT * FROM users WHERE id = ?")
        stmt.setString(1, id)
        val rs = stmt.executeQuery()
        
        return if (rs.next()) {
            User(
                id = rs.getString("id"),
                name = rs.getString("name"),
                email = rs.getString("email"),
                createdAt = kotlinx.datetime.Instant.fromEpochSeconds(rs.getLong("created_at"))
            )
        } else null
    }
    
    actual override suspend fun saveUser(user: User): User {
        val stmt = connection.prepareStatement("""
            INSERT INTO users (id, name, email, created_at) 
            VALUES (?, ?, ?, ?) 
            ON CONFLICT(id) DO UPDATE SET 
                name = excluded.name,
                email = excluded.email,
                created_at = excluded.created_at
        """)
        
        stmt.setString(1, user.id)
        stmt.setString(2, user.name)
        stmt.setString(3, user.email)
        stmt.setLong(4, user.createdAt.epochSeconds)
        stmt.executeUpdate()
        
        return user
    }
    
    actual override suspend fun getAllUsers(): List<User> {
        val stmt = connection.createStatement()
        val rs = stmt.executeQuery("SELECT * FROM users ORDER BY created_at DESC")
        
        val users = mutableListOf<User>()
        while (rs.next()) {
            users.add(User(
                id = rs.getString("id"),
                name = rs.getString("name"),
                email = rs.getString("email"),
                createdAt = kotlinx.datetime.Instant.fromEpochSeconds(rs.getLong("created_at"))
            ))
        }
        return users
    }
}

// androidMain/kotlin/DatabaseRepository.android.kt
import android.content.Context
import androidx.room.*
import androidx.room.Room

@Entity(tableName = "users")
data class UserEntity(
    @PrimaryKey val id: String,
    val name: String,
    val email: String,
    val createdAt: Long
)

@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE id = :id")
    suspend fun getUser(id: String): UserEntity?
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: UserEntity): Long
    
    @Query("SELECT * FROM users ORDER BY createdAt DESC")
    suspend fun getAllUsers(): List<UserEntity>
}

@Database(entities = [UserEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

actual class DatabaseUserRepository actual constructor() : UserRepository {
    private val database by lazy {
        Room.databaseBuilder(
            AndroidContext.getApplicationContext(),
            AppDatabase::class.java,
            "app_database"
        ).build()
    }
    
    actual override suspend fun getUser(id: String): User? {
        return database.userDao().getUser(id)?.let { entity ->
            User(
                id = entity.id,
                name = entity.name,
                email = entity.email,
                createdAt = kotlinx.datetime.Instant.fromEpochSeconds(entity.createdAt)
            )
        }
    }
    
    actual override suspend fun saveUser(user: User): User {
        val entity = UserEntity(
            id = user.id,
            name = user.name,
            email = user.email,
            createdAt = user.createdAt.epochSeconds
        )
        database.userDao().insertUser(entity)
        return user
    }
    
    actual override suspend fun getAllUsers(): List<User> {
        return database.userDao().getAllUsers().map { entity ->
            User(
                id = entity.id,
                name = entity.name,
                email = entity.email,
                createdAt = kotlinx.datetime.Instant.fromEpochSeconds(entity.createdAt)
            )
        }
    }
}

// iosMain/kotlin/DatabaseRepository.ios.kt
import platform.Foundation.*

actual class DatabaseUserRepository actual constructor() : UserRepository {
    private val userDefaults = NSUserDefaults.standardUserDefaults
    
    actual override suspend fun getUser(id: String): User? {
        val key = "user_$id"
        val userData = userDefaults.dataForKey(key) ?: return null
        
        val jsonString = NSString.create(userData, NSUTF8StringEncoding) as String
        return try {
            kotlinx.serialization.json.Json.decodeFromString<User>(jsonString)
        } catch (e: Exception) {
            null
        }
    }
    
    actual override suspend fun saveUser(user: User): User {
        val key = "user_${user.id}"
        val jsonString = kotlinx.serialization.json.Json.encodeToString(user)
        val data = (jsonString as NSString).dataUsingEncoding(NSUTF8StringEncoding)
        
        userDefaults.setObject(data, key)
        userDefaults.synchronize()
        
        return user
    }
    
    actual override suspend fun getAllUsers(): List<User> {
        val allKeys = userDefaults.dictionaryRepresentation().keys
        val userKeys = allKeys.filterIsInstance<String>().filter { it.startsWith("user_") }
        
        return userKeys.mapNotNull { key ->
            val userData = userDefaults.dataForKey(key) ?: return@mapNotNull null
            val jsonString = NSString.create(userData, NSUTF8StringEncoding) as String
            try {
                kotlinx.serialization.json.Json.decodeFromString<User>(jsonString)
            } catch (e: Exception) {
                null
            }
        }
    }
}

Testing Multiplatform Code

// commonTest/kotlin/UserServiceTest.kt
import kotlin.test.*
import kotlinx.coroutines.test.runTest

class UserServiceTest {
    
    private class TestUserRepository : UserRepository {
        private val users = mutableMapOf<String, User>()
        
        override suspend fun getUser(id: String): User? = users[id]
        
        override suspend fun saveUser(user: User): User {
            users[user.id] = user
            return user
        }
        
        override suspend fun getAllUsers(): List<User> = users.values.toList()
    }
    
    private lateinit var userService: UserService
    private lateinit var repository: TestUserRepository
    
    @BeforeTest
    fun setup() {
        repository = TestUserRepository()
        userService = UserService(repository)
    }
    
    @Test
    fun `should create user with valid email`() = runTest {
        // Given
        val name = "John Doe"
        val email = "[email protected]"
        
        // When
        val result = userService.createUser(name, email)
        
        // Then
        assertTrue(result.isSuccess)
        val user = result.getOrNull()!!
        assertEquals(name, user.name)
        assertEquals(email, user.email)
        assertTrue(user.id.startsWith("user_"))
    }
    
    @Test
    fun `should fail to create user with invalid email`() = runTest {
        // Given
        val name = "John Doe"
        val email = "invalid-email"
        
        // When
        val result = userService.createUser(name, email)
        
        // Then
        assertTrue(result.isFailure)
        assertTrue(result.exceptionOrNull() is IllegalArgumentException)
    }
    
    @Test
    fun `should get user profile successfully`() = runTest {
        // Given
        val user = User(
            id = "test123",
            name = "Jane Doe",
            email = "[email protected]",
            createdAt = kotlinx.datetime.Clock.System.now()
        )
        repository.saveUser(user)
        
        // When
        val result = userService.getUserProfile(user.id)
        
        // Then
        assertTrue(result.isSuccess)
        val profile = result.getOrNull()!!
        assertEquals(user, profile.user)
        assertNotNull(profile.lastActive)
    }
    
    @Test
    fun `should handle user not found`() = runTest {
        // Given
        val nonExistentId = "does-not-exist"
        
        // When
        val result = userService.getUserProfile(nonExistentId)
        
        // Then
        assertTrue(result.isFailure)
        assertTrue(result.exceptionOrNull() is NoSuchElementException)
    }
}

// Platform-specific tests
// jvmTest/kotlin/PlatformTest.jvm.kt
import kotlin.test.*

class JvmPlatformTest {
    
    @Test
    fun `should return JVM platform name`() {
        val platform = getPlatform()
        assertTrue(platform.name.startsWith("JVM"))
    }
    
    @Test
    fun `should check online status`() = runTest {
        val platform = getPlatform()
        // Note: This test depends on network connectivity
        val isOnline = platform.checkUserOnline("test")
        // Just verify it doesn't throw an exception
        assertNotNull(isOnline)
    }
}

Build Configuration

// gradle.properties
kotlin.code.style=official
kotlin.mpp.enableCInteropCommonization=true
kotlin.mpp.androidSourceSetLayoutVersion=2

android.useAndroidX=true
android.nonTransitiveRClass=true

# CocoaPods
kotlin.native.cocoapods.platform=ios
kotlin.native.cocoapods.archs=arm64

# Compose Multiplatform
org.jetbrains.compose.experimental.uikit.enabled=true

// gradle/libs.versions.toml
[versions]
kotlin = "1.9.20"
compose = "1.5.4"
ktor = "2.3.5"
coroutines = "1.7.3"
datetime = "0.4.1"
serialization = "1.6.0"

[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }

ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }

# Platform-specific
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-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }

[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Multiplatform Best Practices

  • Common First: Start with common code, add platform-specific only when needed
  • Expect/Actual: Use for platform-specific APIs, avoid overuse
  • Dependency Management: Choose libraries with multiplatform support
  • Testing: Test common code thoroughly, add platform-specific tests for unique functionality
  • Architecture: Design with multiplatform in mind from the beginning

Common Challenges

  • Platform APIs: Not all platform APIs have multiplatform equivalents
  • Build Complexity: Managing multiple targets increases build complexity
  • Debugging: Platform-specific issues can be harder to debug
  • Performance: Ensure performance is acceptable on all target platforms

Use Cases

  • Business Logic: Share domain models, validation, and business rules
  • Networking: Common API clients and data models
  • Data Processing: Shared algorithms and data transformations
  • Utilities: Common utility functions and extensions

Practice Exercises

  1. Create a multiplatform library for user authentication
  2. Build a cross-platform note-taking app with local storage
  3. Implement a shared data synchronization layer
  4. Design a multiplatform analytics SDK

Architecture Notes

  • Code Sharing: Typically 60-90% code sharing depending on application complexity
  • Maintenance: Single codebase reduces maintenance overhead
  • Team Efficiency: Enables smaller teams to target multiple platforms
  • Consistency: Ensures consistent behavior across platforms