Skip to article

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.

DECIDES, OR MERELY PERFORMS DECISIONS written once, in the shared core validation and calendar dates money arithmetic and rounding formatting per locale and currency lifecycle, sessions and staleness URL construction what failure is called MECHANICS written per host, by the host rendering navigation and back stacks keychain, keystore, biometrics network stack and TLS payment sheets file pickers, haptics, sharing if two platforms answered this differently, could a customer tell? yes no
Anything a customer could notice the difference in is a decision, and decisions live in one place.

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.

ONE FUNCTION, FIVE HOSTS FLUTTERMethodChannel · Dart REACT NATIVEnative module · TS iOSXCFramework · Swift ANDROIDJVM artifact · Kotlin WEBexported symbol · JS dispatch( String ): String SHARED CORE validate the query price it in integer minor units format it for the locale check the generation refuse credentials in URLs name the failure { "ok": true, "generation": 4, "checksum": "3a992560d9a5874a", "data": { ... } } A binding may translate. A binding may not decide. The moment one contains an "if" about product behaviour, the product has forked.
Five frameworks, five idioms, one function. The binding may translate. It may not decide.

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.

CONFORMANCE, NOT CONFIDENCE FROZEN CHECKSUM JVM BROWSER NATIVE quote-inr9cb1a794398ce6b9 quote-usd98ab23efe7fea505 quote-late444cd7a1b91d38b0 link2f09f6af1a345b19 money-inr65ca1273f684eb2a money-de89872b3fb5a77220 error-same-airportbf80f1cf857c42ce error-infant-lap0d2cb2fe6b5521d7 error-unknown-command811eaef2a2a35a72 and five more the JVMAndroid links a real browser,Kotlin as JS Kotlin/Native,as Apple links it 83 tests · 3 runtimes · 14 frozen vectors · 0 disagreements, or the build fails
The same suite, the same expected bytes, on three unrelated runtimes. Any disagreement fails the build before it can reach a device.

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.

SAME INPUT. ONE HOST WENT ITS OWN WAY. BOM to DXB · 12 Nov 2026 · 2 adults, 1 child, 1 infant · economy · en-IN FOUR HOSTS ON THE SHARED CORE ₹13,643.70 checksum 3a992560d9a5874a 30 February 2027 refused: invalid_argument on departure ONE HOST WITH ITS OWN COPY ₹15,283.27 checksum d88adc30bf6eb1f7 30 February 2027 accepted: quoted for 2 March instead WHY, IN THREE LINES OF PERFECTLY REASONABLE CODE 1 the infant was charged the statutory seat tax it does not occupy 2 money was held in floating point, because the API returned rupees 3 the date was parsed with the platform Date constructor
The same search on the same screen. Four hosts share the core, one host reimplemented it faithfully. Nothing errors.

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.