Skip to main content

Command Palette

Search for a command to run...

Sharing 80% of Code Across iOS and Android with Kotlin Multiplatform

Updated
5 min read
Sharing 80% of Code Across iOS and Android with Kotlin Multiplatform

When you're one developer building a real product for two platforms, the math is brutal: two codebases, two languages, twice the bugs, half the speed. For FamilyJar — a budgeting app for couples — I didn't have that kind of time. So I went all-in on Kotlin Multiplatform (KMP) with Compose Multiplatform and ended up sharing the large majority of the app across Android and iOS.

Here's how the project is structured, what's actually shared, and the gotchas nobody puts in the "getting started" guides.

What "shared" actually means

People hear "write once, run everywhere" and get suspicious - rightly. KMP isn't that. It's share what makes sense, stay native where it matters. In FamilyJar, the split looks roughly like:

  • Shared (Kotlin): data models, networking, serialization, business logic (the budgeting math, validation), repository layer, and — thanks to Compose Multiplatform — most of the UI.

  • Platform-specific: app entry points, some platform integrations, and the occasional UI affordance that should feel native.

The budgeting domain is a perfect KMP candidate: an Envelope is an Envelope whether it's rendered on a Pixel or an iPhone. The rules for "money in minus money out" don't change per OS.

The module layout

A KMP project leans on a shared module with platform source sets:

composeApp/
  src/
    commonMain/      // shared: models, repos, viewmodels, Compose UI
    androidMain/     // Android-specific actuals + entry point
    iosMain/         // iOS-specific actuals
iosApp/              // Xcode project that hosts the shared framework

commonMain is where the bulk of the code lives. The platform source sets exist mostly to provide implementations of the few things that genuinely differ.

expect/actual: the escape hatch

The one KMP concept you must internalize is expect/actual. You declare an API in common code and implement it per platform:

// commonMain
expect class Platform() {
    val name: String
}

expect fun secureStorage(): SecureStorage
// androidMain
actual class Platform actual constructor() {
    actual val name: String = "Android ${android.os.Build.VERSION.SDK_INT}"
}
// iosMain
actual class Platform actual constructor() {
    actual val name: String =
        UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}

I use this sparingly — for secure token storage (Keystore vs Keychain), platform info, and a couple of integrations. Everything else stays in commonMain.

Networking: one Ktor client for both platforms

FamilyJar talks to a Django + DRF backend over JWT. With Ktor's multiplatform client + kotlinx.serialization, the networking layer is written once:

// commonMain
val httpClient = HttpClient {
    install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
    install(Auth) {
        bearer {
            loadTokens { tokenStore.current() }
            refreshTokens { /* call /api/auth/refresh, persist new tokens */ }
        }
    }
    defaultRequest { url("https://api.familyjar.app/") }
}

@Serializable
data class Expense(
    val id: String,
    val categoryId: String,
    val amount: String,   // money as string — never float
    val note: String? = null,
)

suspend fun expenses(familyId: String): List<Expense> =
    httpClient.get("api/families/$familyId/expenses/").body()

Two things worth calling out:

  • Money is never a Float/Double. It's a string/decimal end to end. Floating-point money is a bug waiting to happen.

  • The Ktor Auth plugin handles JWT refresh transparently, in shared code, so neither platform reimplements it.

UI: Compose Multiplatform in production

This is the part people are still skeptical about, so I'll be blunt: Compose Multiplatform is viable for a real, shipping product. FamilyJar's screens — envelopes, the shared budget view, savings goals — are Composables in commonMain:

@Composable
fun EnvelopeRow(envelope: Envelope, onClick: () -> Unit) {
    val remaining = envelope.allocated - envelope.spent
    Row(/* ... */) {
        Text(envelope.name)
        Spacer(Modifier.weight(1f))
        Text(
            text = remaining.formatMoney(),
            color = if (remaining.isNegative) MaterialTheme.colorScheme.error
                    else MaterialTheme.colorScheme.onSurface,
        )
    }
}

That same Composable renders on both platforms. I keep a shared Material 3 theme (the FamilyJar greens) so the brand is identical everywhere. Where iOS users expect something to feel native, I adjust — but those cases are the exception, not the rule.

The gotchas nobody warns you about

  • iOS still needs iOS attention. You'll open Xcode. You'll fight signing. The shared framework integrates cleanly, but "I never have to touch native iOS" is a myth.

  • Dependencies must be multiplatform. Before adding a library, check it supports the targets you need. A great Android-only lib is useless in commonMain.

  • Build times + tooling. The first iOS build is slow; Gradle + Kotlin/Native take patience. Worth it, but set expectations.

  • expect/actual creep. It's tempting to push things platform-specific. Resist — every actual is code you write twice. Keep the shared layer fat.

  • Threading/coroutines. Structured concurrency works great in common code; just be deliberate about dispatchers at the platform boundary.

Was it worth it?

For a solo founder: absolutely. KMP turned "two apps" into "one app with two front doors." The budgeting logic — the part that has to be correct — is written, tested, and fixed in exactly one place. When I ship a feature, it lands on both platforms at once.

If you're weighing KMP for a real product and not just a demo, my advice: commit to a fat commonMain, keep money as decimals, and accept that iOS will still ask for a little native love. The leverage is real.


I'm Rafał Gawlik, founder of FamilyJar, a privacy-first budgeting app for couples built with Kotlin Multiplatform. More at rafalgawlik.com. Questions about KMP? Drop them in the comments.

Kotlin Multiplatform: Sharing 80% of Code on iOS & Android