DEV Community

Cover image for I Came Back to Kotlin for KMP — Here’s What Broke First
Raul Arroyo
Raul Arroyo

Posted on

I Came Back to Kotlin for KMP — Here’s What Broke First

Part 1 — Why KMP, Why Now, and the First Walls

I've been building mobile apps since 2011. Java with Eclipse, back when ADT was the only game in town and a simple ListView took half a day to get right. Then Kotlin came along and everything got better. Then Flutter came along and I spent the last three years living in Dart land — and honestly, it was great. But something kept pulling me back.

Late last week I decided to come back to Kotlin. Not for a side project or a tutorial — for something real. I collaborate with a music magazine that had just updated their WordPress backend and refreshed their theme. A working REST API, fresh content, a product I actually care about. It was the perfect excuse to dive into Kotlin Multiplatform.

I'd been watching KMP from a distance for years. It always seemed almost ready. Then in late 2023 it went stable, the tooling caught up, and the library ecosystem started filling in. The timing finally felt right.

This is the first post in a series documenting what I'm building and everything I'm learning along the way — the good decisions, the bad ones, and the things that just flat out broke.


The Pitch

The project is a reader app backed by a WordPress REST API — posts, authors, categories, tags, embeds. Nothing exotic on the backend side. The interesting part is entirely in the client.

The bet I'm making: Compose Multiplatform for the entire UI, not just shared business logic. One codebase for every screen on Android and iOS. That's a significant commitment compared to the typical "shared ViewModel, native UI" approach — but coming from Flutter, where you do exactly this, it feels natural. And for someone working alone, eliminating the context switch between two UI stacks is worth a lot.

Here's the full stack:

[versions]
kotlin = "2.3.10"
compose-multiplatform = "1.10.2"
ktor = "3.4.1"
sqldelight = "2.3.1"
koin = "4.1.1"
coil = "3.4.0"
navigation3 = "1.0.0-alpha06"
Enter fullscreen mode Exit fullscreen mode

Each of these deserves its own explanation, because nothing here was obvious.


Why Not Room?

The first real decision was local storage. Coming from Android, Room is the obvious choice — I've used it for years. But Room's KMP support is still marked experimental, and on iOS in particular it can be fragile.

I went with SQLDelight instead. It's been KMP-native from the start. You write plain SQL, it generates fully type-safe Kotlin query functions and a data class for each table. Any mismatch between your schema and your code is a compile error — not a runtime crash at 2am.

The tradeoff is that you lose the annotation-based convenience of Room. But the result is surprisingly clean:

CREATE TABLE postEntity (
    id INTEGER NOT NULL PRIMARY KEY,
    title TEXT NOT NULL,
    excerpt TEXT NOT NULL,
    content TEXT NOT NULL,
    imageUrl TEXT,
    categoryName TEXT,
    categoryId INTEGER,
    slug TEXT,
    tags TEXT,
    isSaved INTEGER AS kotlin.Boolean NOT NULL DEFAULT 0,
    lastModified TEXT NOT NULL
);

selectPostById:
SELECT * FROM postEntity WHERE id = ?;

selectPostBySlug:
SELECT * FROM postEntity WHERE slug = ?;
Enter fullscreen mode Exit fullscreen mode

SQLDelight generates a PostEntity data class and a selectPostById(id: Long): Query<PostEntity> function from this. You call it like normal Kotlin. Adding a column means updating the SQL — and the compiler tells you everywhere that needs to change.


Navigation3 — Alpha Is Alpha

For navigation I went with Navigation3, Jetbrains' new navigation library for Compose Multiplatform. At 1.0.0-alpha06 it's still rough around the edges.

The most painful bug I hit early: double-tapping the back button crashes the app. The backstack empties completely and Navigation3 throws trying to display nothing. The fix ended up being a simple guard:

val isNavigating = remember { mutableStateOf(false) }

fun safeBack() {
    if (!isNavigating.value && backStack.size > 1) {
        isNavigating.value = true
        backStack.removeLastOrNull()
        isNavigating.value = false
    }
}
Enter fullscreen mode Exit fullscreen mode

Not elegant. But it works, and until the library stabilizes it's the pragmatic solution.


Coil, Koin, and the Details

Coil 3 introduced LocalPlatformContext for KMP — replacing Android's LocalContext. If you copy-paste Coil 2 code into a KMP project, images just don't load on iOS with no clear error. The fix is one line, but finding it costs time:

// This is how you do it in KMP — not LocalContext.current
ImageRequest.Builder(LocalPlatformContext.current)
    .data(url)
    .crossfade(true)
    .build()
Enter fullscreen mode Exit fullscreen mode

Koin 4 initializes differently per platform. On Android you call startKoin in Application.onCreate(). On iOS you expose an initKoin() function and call it from Swift before the first Compose frame. The gotcha that bit me: KoinApplicationAlreadyStartedException. If Koin gets initialized twice — easy to do when juggling Android Application classes and iOS entry points — the app crashes immediately on launch with a confusing error message.


The libs.versions.toml Tax

This one isn't unique to KMP but it cost me real time. The libs.versions.toml file has a specific structure — [versions], [libraries], [plugins] — and Gradle's configuration cache is unforgiving about mistakes. I had library declarations mixed into the [versions] block and the error message pointed to something completely unrelated. The fix was formatting. The lesson was to read the TOML spec carefully upfront.


Coming from Flutter

One thing I didn't expect: the mental model shift from Flutter to Compose Multiplatform is smaller than I thought. Both are declarative UI frameworks with a component tree and reactive state. The Dart-to-Kotlin switch is almost a relief — I'm back in a language I know deeply, with IDE support that actually works.

What's different is the platform integration story. Flutter abstracts away the platform almost completely. KMP lets you go as native as you want, which is both powerful and occasionally annoying. Platform channels become expect/actual declarations. The splash screen, share functionality, and back gesture all need platform-specific handling that Flutter would paper over for you.

Whether that's a feature or a bug depends on what you're building. For an app that needs to feel native on both platforms, it's a feature.


What's Next

In Part 2 we'll get into the architecture — why we split posts into two domain models (PostSummary and PostDetail), how the data flows from the WordPress API through SQLDelight to the UI, and how MVI fits into a KMP ViewModel.


Stack: Kotlin 2.3.10 · Compose Multiplatform 1.10.2 · Ktor 3.4.1 · SQLDelight 2.3.1 · Koin 4.1.1 · Coil 3.4.0 · Navigation3 1.0.0-alpha06


Thoughts?

If you're exploring Kotlin Multiplatform right now, I'm curious — what’s been your biggest friction point so far?

Have you tried going full Compose Multiplatform for UI, or are you sticking to shared logic only?

Drop a comment, I'd love to compare notes (and maybe avoid a few future headaches).

Top comments (0)