How We Brought Kotlin Multiplatform Into Babbel's Mobile Apps

Learn how Kotlin Multiplatform let us share logic between our iOS and Android apps without repeating the mistakes of our first attempt — and where we drew the line between shared and native code.

By Antoine LamySeptember 30, 2026
Winter photo of two railroad tracks merging into the distance

Nobody starts a code-sharing project because it looks pretty on an architecture diagram. You start it because the same kind of bug keeps reappearing, and because you keep building the same feature twice.

This is the second time Babbel has tried to share code between its mobile apps. The first attempt was removed from production two years after we wrote about it on this blog. The second one is still here: within the team that started it, we're approaching 100% of the domain we own, and the class of bug that motivated the whole thing has disappeared.

If you're weighing shared mobile code — or you've been burned by it before — the useful part of this story is which code we share and which stays native, and which part of the app we moved first.

Why Two Implementations Kept Telling Two Stories

The bug that started this wore two faces:

  • Tracking. An analytics event implemented independently on iOS and Android will, given enough time, stop agreeing with itself — a property named slightly differently here, a trigger placed a few lines earlier in one flow than the other.

  • A/B tests. Worse: an experiment whose entry point wasn't quite in the same place on both platforms. That one is subtle and expensive, because it means the experiment you're comparing isn't the experiment you think you're running.

It wasn't rare. A divergence in a tracking event, or a test triggering from a slightly different spot — once a month, once every two months, something in that order.

Once a month sounds survivable, and honestly, each individual fix was cheap. The cost isn't the fix, it's the class of bug and its impact on the experiment schedule. Two implementations produce two stories, and product decisions get made by reading an analytics dashboard that might paint a slightly different picture than reality.

The second trigger was less philosophical and more visceral, and it came straight from personal experience. The way the team was split meant that often the same feature landed on me for both platforms, so I built it twice, in sequence — writing it on one, then opening the other and writing it again. The duplication was enormous, and I didn't need a metric to notice it. You don't finish that second implementation still wondering whether shared code is worth it.

What Happened The Last Time We Tried

We've been here before, and the record is public. In 2020, a web team documented on this very blog how they integrated React Native into our native mobile apps. They had a ReactJS stack, no native experience, and a genuine need to reach the majority of Babbel users, who were on mobile. React Native's pitch — learn once, write anywhere — was aimed squarely at that situation.

The engineering in that project was good, and I want to be precise about that before I draw any lessons from it. Rather than restructure two shared native monorepos, the team packaged the React Native code as versioned artifacts with Electrode Native — an AAR for Android (Android Archive), a framework for iOS — injected like any other dependency. A bridge layer handled communication with the native side. It was a careful, deliberately low-intrusion integration, and it shipped features that were among the most impactful of that year.

React Native was abandoned entirely in 2022, two years after that article was published.

What strikes me most, reading it back, is that the article had already diagnosed its own ending. Its own list of downsides flagged three things:

  • Nobody else knew the stack. They were the only ones at Babbel who knew React Native, and once feature ownership moved or people left, the code would likely be rewritten.

  • The logic kept being migrated. Business logic migrated out of React Native and into native code. Tracking events fired from the JavaScript side turned out to be fragile when the view detached or the network dropped, so tracking — and eventually network calls — were handed back to the native implementations.

  • Hiring was a unicorn search. They needed a native developer who also wanted to write JavaScript.

Put those three together and you have the failure mode, plus the reason it took two years to play out. The promise was about who could write the code: if you're a web engineer, you can do mobile. But the logic that mattered kept sliding down into the native layer anyway, because it ran into the parts of a mobile app that are irreducibly mobile — offline support, lifecycles, threading, retry strategies.

Every one of those concessions was individually correct. Collectively, they meant that by the end, doing mobile still required doing mobile, and the framework was left holding a UI layer that only one team knew how to maintain.

What are we promising this time?

That was the question I cared about when we started looking at Kotlin Multiplatform. Not whether code sharing works — whether we could say something defensible about what it would do for us. We had a failure to learn from, and we didn't want to repeat it.

So we narrowed the promise until it was. KMP at Babbel is not a claim about who can write mobile code. It's a claim about which code has to be identical everywhere.

Concretely: the shared layer runs from the view model all the way down through to the model — repositories, use cases, persistence and networking included. The UI stays native on both platforms.

Diagram comparing React Native to Kotlin Multiplatform

Both attempts drew the seam in roughly the same place. They disagreed about which half belongs to everyone.

That one sentence is the whole architecture, and it inverts the React Native split. In the earlier project, the shared artifact ended up being largely a UI layer while logic drifted native. We share the logic and keep the UI deliberately, permanently local.

Nobody has to argue about whether a SwiftUI screen should be rendered by something else. What we share is the part where having two implementations was never a feature — the part with exactly one correct answer.

Start At The Leaves Of The Dependency Tree

Picture the app as a dependency tree. The trunk is the code everything else leans on — session, navigation, the core data layer. The leaves are the screens and features that nothing else depends on.

The first thing we migrated was a leaf: the app's landing screen. Offline, no dependencies on the rest of the app, and — not incidentally — it contained tracking.

Why the landing screen came first

We didn't pick it for the amount of code it would save. We picked it to validate the plumbing:

  • the build integrating cleanly into two apps,

  • the artifacts publishing,

  • the tracking behaving identically on both sides.

That was the deliverable. Shared logic was almost a side effect.

This is the part I'd hand to anyone considering the same move. Start with the leaves of the tree and work your way toward the trunk. Don't start by changing a feature that's core to the application and has enormous interdependency with everything else.

The trap

The trap is the mirror image, and it's seductive: the big-bang conversion, or picking the central module first in order to really prove the thing works.

That's where code-sharing projects die. You take on maximum interdependency at precisely the moment you have minimum confidence in your tooling.

Making Kotlin Comfortable From Swift

We hit limitations at the Kotlin/Swift boundary fairly quickly, and this is usually where iOS skepticism stops being reflexive and becomes justified. Left alone, the Kotlin/Native compiler exposes an Objective-C-flavored API to Swift: suspend functions arrive as completion handlers, and enums lose the exhaustiveness Swift is perfectly capable of enforcing.

Roughly, the difference looks like this:

// A Kotlin `suspend` function, as the Objective-C-flavored API exposes it:
loadCopy { copy, error in
   // completion handler, untyped error, no cancellation
}

// The same function, once the Swift API is generated idiomatically:
let copy = try await loadCopy()

We solved almost none of this ourselves. We started with SKIE, Touchlab's compiler plugin, which rewrites the published Swift API into something idiomatic — most valuably for us, exhaustive enums and the automatic conversion of Kotlin coroutines into Swift async sequences.

That gap is now being closed upstream. Kotlin/Native's Swift export generates a Swift API directly from Kotlin sources, with no Objective-C header in the middle — suspending functions call seamlessly and Flows arrive as AsyncSequence without a third-party plugin in the build. We're currently looking at moving to it. It's still Alpha, so for us the open question is timing rather than direction.

The outcome is what matters: we didn't trade iOS ergonomics for shared code. That's the single most common objection from iOS engineers, and it has to be answered before the rest of the argument gets a hearing.

Draw The Boundary Where Ownership Already Is

The organizational decision may be doing more work here than any of the technical ones.

Instead of converting the apps at once and imposing the architecture company-wide, we started inside a single organizational cluster — Growth, which owns onboarding, the paywall, and authentication. That cluster built the shared layer and still owns it. The code lives in its own repository and publishes two artifacts:

  • a Maven library consumed by the Android app,

  • a Kotlin Multiplatform framework consumed by iOS.

Note what that means: we drew the technical boundary to match a boundary that already existed. We didn't set out to share "some code," we shared the domain one cluster already owned. In my experience that's the thing that prevents the orphaned shared module everyone depends on and nobody maintains.

For every other team, the shared layer is just a dependency. Coupling is kept to a minimum. Sometimes a breaking change is necessary, but most of the time you bump the version and everything works as expected. No joint build, no release train, no waiting on another team's CI. The contract is the library's surface, not everyone's build graph.

Where We Stand Today

The scope expanded outward from that first leaf: landing screen, then the language selectors, then the entire onboarding flow — which we didn't merely port but redesigned along the way to be far more flexible for future experimentation. Migration is a good moment to improve something, not just transcribe it.

Today the authentication layer is nearly fully migrated and the paywall is in progress. Within Growth's scope, we're approaching 100% shared code, and moving much faster as we go.

And the original complaint, the one that started all of this?

The divergences have completely disappeared. We began with two implementations telling two different stories and ended with one source of truth.

The Part That Isn't Technical

There's a factor here that rarely makes it into an architecture decision record.

Kotlin is a modern, pleasant language to work with, and that contributes enormously to iOS developers wanting to get on board. It helps that Kotlin and Swift share a lot of their ergonomics, so the shared layer reads as familiar rather than foreign — and that half the people involved, the Android engineers, already knew the language. Without it, this would have been much harder.

I think that's the same concern that led us to SKIE, just one level up. Developer comfort was treated as a real design constraint throughout — in the language, in the generated Swift API, in the decision to leave the UI native.

A shared layer written in something unpleasant doesn't get adopted. It gets tolerated, then resented, then rewritten. Anyone who has removed a cross-platform framework from a production app knows the sequence.

What AI Changes, and What It Doesn't

Everything above describes a project that began before the current wave of AI-assisted development. Programming has changed considerably in the last few months, and it's fair to ask whether an argument for sharing code survives that shift.

It does. If anything, the case for shared code gets stronger.

  • Language preference matters less when you're not the one typing. I argued a section ago that Kotlin being a pleasant language is part of why iOS engineers came along. That still holds for the code people read and review — but personal taste in syntax weighs less when you aren't writing most of it by hand.

  • One change, made once. Two implementations mean coordinating the same change across two agents in two different sessions, then reconciling whatever each of them did. With a shared layer the change is made in one go, and feature parity across platforms becomes a property of the code rather than something you verify afterwards.

The part I didn't see coming: shared code paired with AI-assisted development has more than doubled our team's capacity, with no change in headcount.

A bar chart showing how shared code and AI-assisted development has increased the number of launched experiments at Babbel

The mechanism is mundane. A single engineer now takes a feature from start to finish on both platforms, including the one they know less well: the logic is written once in the shared layer, and what remains on each side is a native UI conventional enough that an agent can carry them through the unfamiliar half. The developer who used to build the same feature twice now builds it once, and work that once took a team — and the coordination that comes with it — can now be carried by one person.

We adopted KMP to stop two implementations from telling two stories. The leverage it turned out to provide, once the tooling shifted underneath us, wasn't something we planned for.

What We'd Do Again

Four things to take from this:

  • Narrow the promise until it's defensible. Ours is about which code must be identical, not about who gets to write mobile code.

  • Invert the seam. Share the logic and keep the UI native. That's the half with exactly one correct answer.

  • Start at the leaves. Validate the plumbing on something offline and dependency-free before you go anywhere near the core.

  • Match the technical boundary to an ownership boundary that already exists. A shared module without an owner becomes the module nobody maintains.

And the one that never makes it onto the architecture diagram: developer comfort is a design constraint, not a nice-to-have. My sincere thanks to everyone in Growth, who met this with real openness, adopted it quickly, and carried the initiative considerably further than I could have taken it myself.

The technology doesn't carry itself. A team does.

Header photo by Lance Grandahl

Learn A New Language

Try Babbel

Share:

Headshot of Antoine Lamy
Antoine Lamy

An iOS developer since the first iPhone SDK in 2008, Antoine loves building mobile products, whether that means shipping something new or reshaping the architecture underneath it, always with an eye on what's coming next. As a Principal Mobile Engineer at Babbel, he works mainly across iOS and Android, sometimes reaching into the backend, and is a committed advocate for Kotlin Multiplatform. Outside of work, Antoine enjoys skiing and traveling — ideally both at once — and spending time with his family. He is a native French speaker and fluent in English.

An iOS developer since the first iPhone SDK in 2008, Antoine loves building mobile products, whether that means shipping something new or reshaping the architecture underneath it, always with an eye on what's coming next. As a Principal Mobile Engineer at Babbel, he works mainly across iOS and Android, sometimes reaching into the backend, and is a committed advocate for Kotlin Multiplatform. Outside of work, Antoine enjoys skiing and traveling — ideally both at once — and spending time with his family. He is a native French speaker and fluent in English.

Recommended Articles