Introduction
By day 62, you've built solid Android skills with Kotlin, but sharing logic with iOS still feels like writing the same code twice. Kotlin Multiplatform: a practical Android & Kotlin guide offers a pragmatic path forward, letting you write business logic once and deploy it across platforms without sacrificing native user experiences.
We've seen this approach work across our teams—from payment processing to real-time data synchronization. Rather than betting everything on a full cross-platform framework, Kotlin Multiplatform lets you adopt gradually, starting with shared networking and business logic while keeping platform-specific code where it belongs.
The Case for Sharing Logic Between Platforms
When you're supporting both Android and iOS, networking, data models, and business rules live in duplicate codebases. A tax calculation bug gets fixed once on Android, three weeks later on iOS. Currency conversion logic diverges slightly. API client retry strategies differ.
Kotlin Multiplatform solves this by letting you write core logic once in commonMain and use it everywhere. Your Android app and iOS app share the same networking layer, the same data validation, the same business calculations.
Consider a ride-hailing platform calculating surge pricing:
// shared/src/commonMain/kotlin/com/rideshare/pricing/SurgeCalculator.kt
class SurgeCalculator(
private val demandMultiplier: Double,
private val supplyDivisor: Int,
private val baseRate: Double
) {
fun calculateFare(distanceKm: Double, durationMin: Int): Double {
val baseFare = baseRate * distanceKm * (durationMin / 60.0)
val surgeFactor = demandMultiplier / supplyDivisor.coerceAtLeast(1)
return (baseFare * surgeFactor * 100).toLong() / 100.0
}
}Both Android and iOS apps instantiate the same SurgeCalculator class with their current market conditions, and both get identical pricing. Bugs fixed once. Features added once. Confidence that both platforms behave the same way.
Shared Networking and Data Models
HTTP clients and JSON serialization are prime candidates for sharing. The API contract is the same whether you're calling it from Android or iOS—why duplicate the layer that talks to it?
Set up Ktor Client in commonMain with platform-specific HTTP engines configured in each native module:
// shared/src/commonMain/kotlin/com/example/api/ApiClient.kt
class ApiClient(private val httpClient: HttpClient) {
suspend fun fetchUser(id: String): User = httpClient.get("users/$id")
.body()
suspend fun createOrder(order: OrderRequest): Order =
httpClient.post("orders") {
contentType(ContentType.Application.Json)
setBody(order)
}.body()
}
// shared/src/commonMain/kotlin/com/example/models/Models.kt
@Serializable
data class User(
val id: String,
val email: String,
val createdAt: Long
)
@Serializable
data class OrderRequest(
val userId: String,
val items: List,
val shippingAddress: String
)
@Serializable
data class Order(
val id: String,
val userId: String,
val status: String,
val total: Double
)Each platform provides its HTTP engine in androidMain and iosMain:
// shared/src/androidMain/kotlin/com/example/api/HttpClientFactory.kt
actual fun createHttpClient(): HttpClient = HttpClient(OkHttp) {
install(ContentNegotiation) {
json()
}
install(HttpTimeout) {
requestTimeoutMillis = 30000
}
}
// shared/src/iosMain/kotlin/com/example/api/HttpClientFactory.kt
actual fun createHttpClient(): HttpClient = HttpClient(Darwin) {
install(ContentNegotiation) {
json()
}
install(HttpTimeout) {
requestTimeoutMillis = 30000
}
}Your data validation, retry logic, caching strategies—all in common code. Both apps deserialize API responses using identical kotlinx.serialization configuration, eliminating subtle JSON parsing bugs.
Business Logic That Stays in Sync
Beyond networking, push order status calculations, user preference logic, and state management into commonMain. If you're using the Kotlin Multiplatform equivalent of Redux or MVI architecture, your core reducers live once.
A travel booking app tracks available seats on flights:
// shared/src/commonMain/kotlin/com/travel/booking/SeatManager.kt
class SeatManager(private val totalSeats: Int) {
private val bookedSeats = mutableSetOf()
fun bookSeat(seatNumber: Int): Result {
return if (seatNumber < 1 || seatNumber > totalSeats) {
Result.failure(Exception("Invalid seat number"))
} else if (bookedSeats.contains(seatNumber)) {
Result.failure(Exception("Seat already booked"))
} else {
bookedSeats.add(seatNumber)
Result.success(Unit)
}
}
fun availableSeats(): List =
(1..totalSeats).filterNot { bookedSeats.contains(it) }
fun occupancyPercent(): Int =
(bookedSeats.size * 100) / totalSeats
}Android calls the same methods as iOS. No drift. When you add a minimum-seatDistance rule or a VIP-override, it applies everywhere.
Native UI on Each Platform or Compose Multiplatform
You don't have to choose between shared business logic and great user experiences. Keep your UI native: XML layouts on Android, SwiftUI on iOS. Let your business logic do the heavy lifting underneath.
On Android, you'd use your shared classes normally:
// androidApp/src/main/kotlin/com/travel/booking/android/SeatSelectionScreen.kt
@Composable
fun SeatSelectionScreen(flightId: String) {
val viewModel: SeatViewModel = hiltViewModel()
val availableSeats by viewModel.availableSeats.collectAsState()
LazyVerticalGrid(columns = GridCells.Fixed(6)) {
items(availableSeats) { seatNumber ->
SeatButton(
seatNumber = seatNumber,
onSelect = { viewModel.bookSeat(seatNumber) }
)
}
}
}
class SeatViewModel @Inject constructor(
private val seatManager: SeatManager
) : ViewModel() {
private val _availableSeats = MutableStateFlow>(emptyList())
val availableSeats = _availableSeats.asStateFlow()
fun bookSeat(seatNumber: Int) {
seatManager.bookSeat(seatNumber)
.onSuccess { _availableSeats.value = seatManager.availableSeats() }
}
}If you want to go further, Compose Multiplatform lets you write declarative UI that compiles to both Android and iOS. You'd use @Composable functions in commonMain and render them with a thin native wrapper on each side. Many teams find this sweet spot: shared business logic and shared UI patterns, with platform conventions respected.
For iOS in that scenario:
// shared/src/commonMain/kotlin/com/travel/booking/SeatSelectionUI.kt
@Composable
fun SeatSelectionScreen(seatManager: SeatManager) {
var availableSeats by remember { mutableStateOf(seatManager.availableSeats()) }
Column {
Text("Select Your Seat")
LazyVerticalGrid(columns = GridCells.Fixed(6)) {
items(availableSeats.size) { index ->
Button(onClick = {
seatManager.bookSeat(availableSeats[index])
availableSeats = seatManager.availableSeats()
}) {
Text("${availableSeats[index]}")
}
}
}
}
}Then your iOS app just imports and renders it. UI code lives once, logic lives once.
expect/actual for Platform-Specific APIs
Not everything can be shared. File I/O, native sensors, push notifications, local storage—these need platform-specific implementations but consistent interfaces.
Kotlin's expect/actual mechanism lets you define a contract in common code and implement it per-platform:
// shared/src/commonMain/kotlin/com/example/storage/SecureStorage.kt
expect class SecureStorage {
suspend fun save(key: String, value: String)
suspend fun retrieve(key: String): String?
suspend fun delete(key: String)
}
// shared/src/androidMain/kotlin/com/example/storage/SecureStorage.kt
actual class SecureStorage(context: Context) {
private val encryptedSharedPrefs = EncryptedSharedPreferences.create(
context,
"secret_prefs",
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
actual suspend fun save(key: String, value: String) {
encryptedSharedPrefs.edit().putString(key, value).apply()
}
actual suspend fun retrieve(key: String): String? =
encryptedSharedPrefs.getString(key, null)
actual suspend fun delete(key: String) {
encryptedSharedPrefs.edit().remove(key).apply()
}
}
// shared/src/iosMain/kotlin/com/example/storage/SecureStorage.kt
actual class SecureStorage {
actual suspend fun save(key: String, value: String) {
SecureStorageHelper.save(key, value)
}
actual suspend fun retrieve(key: String): String? =
SecureStorageHelper.retrieve(key)
actual suspend fun delete(key: String) {
SecureStorageHelper.delete(key)
}
}Common code treats SecureStorage as a single API. Each platform does what's natural for it. No shared code knows about EncryptedSharedPreferences or iOS Keychain—they're implementation details.
Adopting Kotlin Multiplatform Gradually
You don't need to migrate your entire codebase on day one. Start small, prove value, grow incrementally.
Phase one: Create a new shared module alongside existing code. Move pure utility functions there—date formatting, currency conversion, validation helpers. Zero risk because they're not on any critical path yet.
Phase two: Build networking there. If your iOS team hasn't started yet, this is where the savings kick in. One API client, one serialization config, one retry strategy.
Phase three: Gradually migrate business logic. Pick a domain that's stable and low-risk. Maybe it's e-commerce cart calculations. Prove it works identically on both platforms for a few releases.
Phase four: If Compose Multiplatform fits your product timeline, consider it for new screens. Existing Android screens stay as-is. New features can be built once.
This approach respects your existing iOS investment. Your iOS team still owns the iOS codebase. They're just consuming a shared Kotlin Multiplatform library instead of rebuilding logic from scratch.
Common Mistakes
- Trying to share UI too early. Many teams start by trying to share UI before they have shared logic figured out. Start with business logic and APIs. UI sharing comes later once you understand the constraints.
- Ignoring platform differences in shared code. If your common code makes assumptions about thread models, timing, or APIs that don't exist on one platform, you'll hit friction. Test on both platforms early and often.
- Over-sharing. Not everything should be shared. If it's easier for iOS to have its own implementation of something, that's okay. Shared code is a tool, not a goal.
- Neglecting iOS setup complexity. iOS linking and bridging have their own learning curve. Budget time to understand CocoaPods integration if your iOS team isn't already comfortable with it.
- Underestimating serialization differences. JSON parsing edge cases can differ between kotlinx.serialization on each platform. Test with real API responses, not just happy paths.
Key Takeaways
- Kotlin Multiplatform lets you write business logic once and use it on Android and iOS without choosing a cross-platform UI framework.
- Start with shared networking and data models—this is where duplicate code hurts most and shared code wins quickly.
- Use expect/actual to define platform-agnostic contracts for APIs like storage, permissions, and sensors, with platform-specific implementations underneath.
- Native UI on each platform is compatible with shared business logic. Compose Multiplatform is an option for UI sharing later, not a requirement upfront.
- Adopt gradually. Start with utilities and networking, prove value, migrate business logic incrementally, and consider UI sharing only when the team is ready.
- Kotlin Multiplatform is not a replacement for understanding both Android and iOS. It's a way to reduce duplicate effort on platform-agnostic code while keeping platform-specific concerns where they belong.






Comments
Loading comments…