Building Across Kotlin and Swift Without Blurring Platform Boundaries
A practical way to divide shared domain behavior, state, packaging, and native UI responsibilities across Kotlin and Swift.
[systems debrief] 5 sections
Kotlin and Swift can share a product without pretending the two platforms are identical. The useful question is not “How much code can I reuse?” It is “Which decisions should have one owner, and which decisions should remain native to Android and iOS?”
Compose Multiplatform and SwiftUI make several arrangements possible. The durable one depends less on the headline reuse percentage than on the boundary between domain behavior, state, and platform experience.
01. Share rules before sharing screens
Validation, request construction, persistence policy, and domain state are strong candidates for one implementation when both apps must make the same decision. Duplicating those rules creates two places for edge cases to drift.
UI is different. Navigation, accessibility, text input, system sheets, back behavior, and lifecycle expectations belong to the platform. Sharing a screen is worthwhile when the experience is genuinely common and the shared layer can still expose the hooks each platform needs. It is not worthwhile when every native behavior becomes an exception.
02. Design the state boundary explicitly
The shared layer should expose a small state and event contract rather than leaking its internal architecture into Swift. Names, nullability, collections, errors, and asynchronous work all cross the boundary differently than they feel inside Kotlin.
Questions I ask at the boundary:
- Can Swift understand the model without knowing Kotlin implementation details?
- Is cancellation tied to the correct native lifecycle?
- Are failures typed well enough for each UI to choose its own presentation?
- Does one event have one owner, or can both shells trigger it accidentally?
- Can the shared behavior be tested without either UI framework?
03. Keep the native shell honest
A thin shell should still be a real native application layer. SwiftUI should own iOS navigation, scene behavior, accessibility, and system integrations when those are native concerns. Compose should do the same on Android. The shared core can decide that a session expired; each platform decides how that fact becomes a screen transition or alert.
This avoids two common failures: rebuilding platform conventions inside shared code, and leaving so much behavior in the shells that the “shared” core is only a set of data classes.
04. Packaging and interop are product work
A shared module is not finished when its Kotlin tests pass. The iOS framework must build for the required targets, Swift names must remain usable, and dependency updates must not surprise one platform days after the other has integrated them. Generated interfaces are part of the public API even when they never leave the repository.
I prefer a small integration sample or smoke path on both platforms. It catches the class of failure that unit tests cannot: the core is correct, but the artifact, symbol, lifecycle, or binding is wrong.
05. Reuse is an outcome, not the goal
The best cross-platform boundary reduces duplicated reasoning while preserving a native product experience. Sometimes that means a shared domain core with SwiftUI and Compose screens. Sometimes a shared Compose screen is the simpler choice. The decision should follow the feature, team, and integration cost—not a target reuse percentage.
Kotlin and Swift work well together when each side has a clear job. The shared layer owns the decisions that must agree. The native layers own the experiences that should feel at home.