One core, five hosts, one answer
We were about to write the same booking rules for the fourth time. Instead we moved every decision a customer could notice into a single Kotlin Multiplatform core, gave each framework a binding thin enough to read in a sitting, and made all of them prove they agree.
A partner asks for the flights experience inside their app. Their app is Flutter. You ship a Flutter SDK. The next partner is on React Native. The one after that has a native iOS team with opinions about pods, and their Android counterpart wants an AAR.
Four requests, four reasonable answers, and by the fourth you are maintaining four implementations of the same product. Nobody decided to do that. It happens one sprint at a time, and it is only obvious in hindsight.
We got to the third and stopped.
The bug that never crashes
The failure mode of a multi-implementation product is not a crash. Crashes get fixed on Tuesday. The failure mode is quiet disagreement.
Three real examples, all of which have shipped somewhere in this industry:
A price formats correctly in four places and wrongly in one. Every platform has a number formatter and most of them group digits in threes. That is right in London and wrong in Mumbai, where a hundred and twenty thousand rupees is written ₹1,20,000 and not ₹120,000. One platform gets an Indian-grouping ticket, three do not, and the app that shows the wrong shape is the one your largest market uses.
A date that does not exist gets accepted. new Date("2027-02-30") does not throw in JavaScript. It returns the second of March. Dart, Swift and Kotlin each have a different opinion about that string, and only one of them is loud about it. A traveller picks a date, one platform silently moves it, and the supplier rejects a booking nobody can reproduce.
An infant is charged for a seat. An infant travels on an adult's lap, so it pays a percentage of the fare and does not occupy a seat for tax purposes. That rule is two sentences long, which is exactly why it gets re-typed instead of shared, and exactly why one of the four copies gets it wrong by twelve percent.
None of these produce an error. They produce a support ticket six weeks later, and an afternoon of everyone loading the same itinerary on four devices.
The line we drew
The fix is not a shared UI framework. We tried thinking about it that way and it leads to the worst of both worlds: a rendering layer that fights every platform, wrapped around business rules that are still duplicated underneath.
The useful line runs somewhere else. On one side sit the things the product decides. On the other sit the things the operating system performs.
Everything on the left is a product decision. It goes in one place, in one language, and every host asks for the answer rather than computing it. Everything on the right is platform work that the platform is already good at, and every abstraction we tried to build over it produced a worse version of what the framework gives you free.
For us that one place is a Kotlin Multiplatform core. Kotlin because our backend teams already write it and the language is pleasant to keep rules in. Multiplatform because it compiles to a JVM artifact for Android, to a static XCFramework for anything Apple, and to JavaScript for the browser, from one source revision.
The entire host API
Here is the whole surface a host binding has to cover:
fun dispatch(envelope: String): String
A JSON envelope in, a JSON envelope out.
That looks primitive next to a generated typed interface, and we chose it deliberately, because it buys four properties that matter more than ergonomics at the boundary.
No framework types reach the core. Nothing about MethodChannel, Promise, NSDictionary or ReadableMap gets anywhere near a fare rule, so adding a host is additive instead of invasive.
Nothing throws across the boundary. A Kotlin exception surfacing in Swift, in Dart and in JavaScript is three different debugging experiences for one bug, and none of them can be handled by a client written against a contract. Failure is a value with a code and a field.
Output is comparable. The same command produces the same bytes on every target, which is the property that makes the rest of this post possible.
The binding stays small. Each one is roughly a page. That is not an aesthetic preference. A binding you can read in one sitting is a binding you can keep product logic out of.
The cost is a JSON encode and decode per call. For a search, a quote or a lifecycle event, which is what actually crosses this boundary, that cost disappears next to the network request behind it.
Two rules that keep the core honest
The core has no clock. Validation takes the date from the host: SearchQuery.from(..., today = hostDate). This looks fussy until you try to test a booking horizon. A core that reads the system clock cannot produce the same answer twice, let alone the same answer on three runtimes.
Every lifecycle event bumps a generation. Configure is generation one. A session change is two. A sign-out is three. Anything still in flight carrying an older number is stale by definition, so a sign-out that lands while a search is running cannot resolve into the new session's screen. One counter replaces four platform teams' individual guesses about when a screen has gone out of date.
Ambient state the core genuinely needs, like a token or a payment sheet, is requested from the host as a declared capability. The core says what it needs, the host answers on its own terms, and the answer is never retained. A capability a host did not declare at configure time cannot be requested at all.
Proof beats intent
Everything above is architecture, which is to say it is a promise. Promises about shared behaviour are exactly the thing that quietly stops being true.
So the core hashes every response. Sixteen hex characters, FNV-1a over the response body, returned in the envelope alongside the data. It costs nothing and it converts a claim into a measurement: when the iOS panel, the Android panel and the browser print the same checksum for the same search, they are not asserting they share code, they are demonstrating it.
The test suite freezes those checksums for fourteen commands and runs on every runtime we ship to.
The contract is generated from the core too, not written alongside it. A Gradle task exports the command list, the argument types, the error codes and the credential deny list to JSON, and a test fails the build when the checked-in copy has drifted. A contract that lives in a document is a description of what somebody intended in March.
Watch it break on purpose
The argument for all of this is easier to feel than to read, so we built the demo that shows the counterfactual.
Five panels, five independent runtime instances of the same core, all answering at once. Then a switch labelled let this host keep its own implementation, which re-points one panel at a hand-written copy of the rules. Not a lazy copy. The kind a good engineer writes in an afternoon when the rules live in another team's repository and the ticket says match the other platform.
Twelve percent. No exception, no red screen, no log line. A price that is simply wrong on one platform, and a date that silently moves, on the host whose users happen to be the ones who notice last.
That is the entire case for the architecture, and it is more persuasive as a toggle than as a paragraph.
What it costs
I would be describing a different technology if I left this out.
The toolchain is heavier. Kotlin/Native link times are not fun. A cold CI run that builds an XCFramework is minutes, not seconds. We keep the JVM and JavaScript test jobs on the critical path and the Apple job in parallel, because ninety-five percent of failures are caught by the first two.
Debugging crosses a boundary. A stack trace stops at the bridge. This is a real tax, and it is why every failure carries a code, a field and a checksum: you get identity even when you cannot get a trace.
Somebody has to own the contract. The core is a shared dependency with four consumers, which is an organisational fact before it is a technical one. Adding a command is cheap. Changing the meaning of one is not, and the deploy schedules of four host apps do not line up.
It is the wrong shape for one host. If you ship to a single platform, everything above is overhead with no payoff. The economics turn at the second host, and they turn hard at the third.
Take it apart yourself
The pattern is worth more in your hands than in a diagram, so we open sourced a clean-room implementation of it.
github.com/OnArrival/kmp-bridge has the core, the four bindings, the conformance suite, the generated contract, and sample apps for Android, iOS, Flutter and React Native. It is Apache-2.0.
The live demo is the five-panel page described above, including the drift switch. There is no build step and no server: the page loads about ninety kilobytes of Kotlin compiled to JavaScript and runs it in the tab.
The fares in it are invented. The arithmetic, the validation, the lifecycle and the contract are the real thing, and the same eighty-three tests run on the JVM, in a browser and on Kotlin/Native, which you can verify in about a minute:
./gradlew :core:jvmTest :core:jsTest :core:iosSimulatorArm64Test
Our production SDK is larger and has a lot of travel in it that a demo has no business containing. The parts worth copying are the ones in the repository: put the decisions in one place, keep the bindings too small to hide logic in, and make every host prove it agrees rather than promise it does.
The fourth implementation is always the cheapest one to skip.