
At IPS, we’re often asked to develop mobile applications that target both Android and iOS. That requirement immediately raises an important architectural decision: should we build and maintain separate native applications for each platform, or use a cross-platform approach that allows us to share core logic while still supporting platform-specific behavior?
On one hand, we could maintain separate native codebases for Android and iOS, each with its own repository, build pipeline, and issue tracking process. While this approach provides full control over each platform, it also requires extra coordination to maintain feature parity, align test protocols, and prevent the two implementations from drifting apart. In practice, this can significantly increase the effort required to deliver the same level of quality across both operating systems.
Alternatively, we could use a cross-platform framework to implement much of the application once and deploy it across multiple targets, branching into native platform code only where needed. This approach can reduce duplication and streamline development compared with separate native codebases, but it only succeeds when supported by disciplined architecture and engineering practices. The application must be structured so its abstractions keep the shared codebase flexible, maintainable, and scalable while still respecting the constraints and conventions of each target platform.
Having experimented with several multiplatform frameworks and technologies, we found that Kotlin Multiplatform gave us the reliable foundation we were looking for. By moving common business logic, data contracts, and reusable application behavior into shared Kotlin code, we reduced duplication and created a single source of truth for core functionality. At the same time, Kotlin’s platform-specific mechanisms let us keep the Android and iOS implementations lean, focused, and native where it mattered most.
This article focuses on the architectural decisions behind a Kotlin Multiplatform implementation for Android and iOS, using a BLE service layer as the primary example. Rather than presenting KMP as a universal replacement for native development, it shows where shared code created real value, where platform-specific implementations remained necessary, and how clear boundaries helped keep the application maintainable.
Over the past few years, I’ve become IPS’s internal advocate for using KMP on mobile projects that target both Android and iOS. After inheriting and maintaining multiplatform applications built with frameworks like Xamarin and MAUI, it became clear that we needed a more practical alternative for shared mobile development. Once we launched a new project designed around KMP, the benefits were immediately apparent, and it quickly became our preferred approach moving forward.
What made KMP stand out was that it felt designed around the realities of asynchronous mobile development. Kotlin coroutines, structured concurrency, and clear dispatcher usage gave us practical guardrails for keeping background work off the main UI thread without adding unnecessary complexity. Xamarin and MAUI offered similar concepts in places, but in our experience, they required more discipline from the development team to avoid threading issues. We also encountered memory management concerns in early MAUI builds, particularly around global references. Although those issues were expected to improve over time, the framework was not mature enough for the needs of this project.
Like other multiplatform frameworks, KMP enables shared code across multiple platforms, from core business logic to broader application layers. In our case, the greatest value came from sharing logic that needed to behave consistently on every device, including data models, domain rules, transformations, validation, service-facing interfaces, and diagnostics. KMP made these parts simple to implement. The real challenge was handling mobile hardware components and interfaces that were not natively supported by KMP libraries and could vary significantly by platform.
Our goal was to build a Bluetooth Low Energy (BLE) stack wrapper in common code, using Android and iOS APIs only where necessary. Although this is theoretically possible with Xamarin and MAUI, it proved much harder in practice. From the start, our team found that KMP exposed several advantages that were harder to realize in those other frameworks.
One of the most important Kotlin Multiplatform concepts we used was the expect/actual mechanism. Shared code defines an expect declaration as a platform-agnostic contract, and each target platform supplies its own actual implementation. This creates a clean separation: common code works against the contract much like it would an interface, without needing to know the platform-specific details so long as the contract is fulfilled according to specification.
In this pattern, the shared module defines what the application needs, while each platform module defines how that need is fulfilled. That distinction kept the BLE service contract stable in common code while still allowing Android and iOS to use their native Bluetooth APIs behind the boundary.
This separation lets us organize the code into distinct source sets, with one for shared common logic and one for each target platform. The common source set remains independent of platform-specific APIs, while the Android and iOS source sets provide the integrations needed for their respective environments. See the following diagram:

The abstract BleService class defines the core functions that each platform implementation must provide. AndroidBleService and IosBleService supply those implementations within their respective androidMain and iosMain source sets, which are configured to target the Android and iOS APIs.
The App class represents the part of the application that consumes BleService. It depends on the platform-agnostic service reference, and the expect/actual configuration resolves that reference to the appropriate implementation: AndroidBleService on Android or IosBleService on iOS.
The following brief code examples show how simple the implementation can be.
1. In a commonMain source file, declare the BleService contract and an expected service reference that shared code can consume without depending on Android or iOS APIs.

2. In an androidMain source file, extend BleService with AndroidBleService and provide the actual implementation of the expected bleService variable.

3. Repeat the same pattern in the iosMain source set.

4. Finally, in the commonMain app implementation, consume bleService through its platform-agnostic reference. Kotlin resolves that expected reference to the appropriate actual implementation from the target source set, allowing shared code to call bleService transparently while Android and iOS provide the native behavior.

As the application architecture matured, we saw an opportunity to streamline the expect/actual implementations. The abstract BleService class could own reusable behavior while still calling into platform-specific APIs when necessary. For example, when scanning for BLE peripherals, adding a discovered device to a collection did not require a
platform-specific implementation. The shared logic only needed to match the peripheral name and update the common list of discovered peripherals.
To support this, we applied the Template Method pattern: BleService defined the common workflow, while AndroidBleService and IosBleService handled only the platform-specific steps required along the way.
The following example shows peripheral scanning in AndroidBleService. BleService owns the shared workflow and state, while AndroidBleService supplies the smaller Android-specific operations required along the way. This keeps the common logic centralized and limits each platform implementation to the behavior that truly depends on native APIs.

Centralizing the workflow solved only part of the problem. The architecture also needed a disciplined way to move data between shared Kotlin code and the native Android and iOS APIs without letting platform types leak into the common layer.
A key part of making the architecture sustainable was treating the boundary between shared code and platform code deliberately. Common data interfaces were defined in a way that made sense to the shared Kotlin layer. When data needed to cross into Android or iOS, we translated it into the appropriate platform representation. When platform-specific results came back, we translated them into common interfaces before returning them to the shared workflow.
This translation layer gave us flexibility. The shared code did not need to understand Android framework types or iOS-native structures, and the platform code did not need to own business rules that belonged in common logic. Each side of the boundary had a clear purpose, which made the system easier to reason about, test, and evolve.
The biggest lesson was that Kotlin Multiplatform works best when shared code is intentional. Not everything needs to be shared, and not every platform difference should be abstracted away. The architecture was strongest where we shared durable business logic and created small, well-defined seams for platform behavior. Expect/actual worked best as a capability contract, not a catch-all for platform differences. Combined with functional templating and disciplined data translation, that approach kept the common code powerful without tying it to either mobile platform.
For teams evaluating Kotlin Multiplatform, the practical takeaway is to start by identifying the logic that must behave consistently everywhere, then design narrow platform seams around the capabilities that cannot be shared. That approach lets KMP provide leverage without hiding the native platform details that still matter.