Quizzr Logo

Mobile App Paradigms

Implementing Shared Business Logic with Kotlin Multiplatform (KMP)

Discover the 'logic-first' approach to cross-platform development that keeps UI native while sharing 90% of business and data layer code.

Mobile DevelopmentIntermediate12 min read

The Evolution of Cross-Platform Development

For years, mobile developers have been forced to choose between the performance of native development and the efficiency of cross-platform frameworks. Native development requires maintaining two separate codebases, which often leads to logic drift where business rules behave differently on iOS and Android. Cross-platform frameworks like Flutter or React Native solve the duplication problem but often introduce a custom rendering engine or a bridge that can degrade the user experience.

The logic-first paradigm represents a significant shift in this architectural debate by separating the concerns of business logic and user interface. This approach suggests that while the UI should be native to ensure the best possible performance and accessibility, the underlying data layers do not need to be platform-specific. By sharing the core logic, teams can achieve a high level of code reuse without sacrificing the high-fidelity feel of a truly native application.

This architectural pattern is primarily enabled by technologies like Kotlin Multiplatform which allow for the compilation of shared code into native libraries. On iOS, the shared code becomes a framework that Swift can consume directly, while on Android, it remains a standard library. This allows engineers to write their networking, persistence, and validation logic once while keeping the presentation layer completely decoupled.

The goal of logic-first development is not to write the entire app once, but to ensure the most critical and complex parts of your application are only written and tested a single time.

A major advantage of this approach is the reduction in testing overhead and the elimination of duplicate bugs across platforms. When a calculation error is fixed in the shared module, the fix propagates to both platforms simultaneously after a rebuild. This consistency is vital for applications handling complex financial transactions, healthcare data, or offline synchronization logic.

The Shared Kernel Architecture

At the heart of a logic-first application is the shared kernel, a module containing the pure business logic and data structures. This module is typically written in a language that can target multiple platforms, such as Kotlin or Rust, and is designed to be agnostic of the platform it runs on. It defines how data is fetched, transformed, and stored, but never how it is displayed to the user.

By keeping the shared kernel thin and focused, developers avoid the common pitfalls of monolithic cross-platform frameworks. The shared layer communicates with the native UI through reactive streams or simple callbacks, allowing each platform to handle its own lifecycle events. This separation ensures that an Android Fragment or an iOS SwiftUI View can interact with the same data source using their own idiomatic patterns.

Implementing the Data and Network Layer

In a real-world scenario, the majority of mobile app code is dedicated to communicating with APIs and persisting that data locally. A logic-first approach treats these tasks as platform-independent services that reside within the shared module. This allows for a unified networking client configuration, including headers, retry logic, and authentication interceptors.

Modern libraries such as Ktor provide a multiplatform engine that uses the underlying native networking stacks like URLSession on iOS and OkHttp on Android. This ensures that the application still benefits from platform-level optimizations and security features. The following example demonstrates a shared repository that manages user profile data using a logic-first approach.

kotlinShared Data Repository Implementation
1class UserProfileRepository(private val api: UserApiService, private val database: LocalDatabase) {
2    // Fetches profile from network and persists to local storage
3    suspend fun refreshUserProfile(userId: String): Result<Profile> {
4        return try {
5            val remoteProfile = api.fetchProfile(userId)
6            database.saveProfile(remoteProfile)
7            Result.success(remoteProfile)
8        } catch (e: Exception) {
9            // Fallback to local cache if network fails
10            val cached = database.getProfile(userId)
11            cached?.let { Result.success(it) } ?: Result.failure(e)
12        }
13    }
14}

The repository pattern shown above acts as a single source of truth for both mobile platforms. Since the logic for error handling and caching is contained within this class, the mobile clients only need to observe the resulting data. This drastically reduces the surface area for platform-specific bugs and ensures that the offline-first experience is identical for all users.

Cross-Platform Persistence with SQL

Local persistence is another area where logic-first development excels through tools like SQLDelight. Instead of writing separate CoreData logic for iOS and Room logic for Android, developers write standard SQL queries that generate type-safe code for both targets. This ensures that the database schema is always in sync and migrations are handled consistently.

Using a shared database layer also simplifies complex features like full-text search or encrypted storage. By implementing these features once in the shared module, you avoid the risk of subtle differences in indexing or encryption algorithms between platforms. This level of consistency is particularly important for applications that must meet strict regulatory compliance standards.

Bridging Platforms with Expect and Actual

Despite the goal of maximum sharing, some features inevitably require access to native platform APIs. Whether it is accessing the device's keychain, interacting with the camera, or getting the current battery level, a bridge is necessary. The logic-first paradigm handles this through a contract-based system known as the expect/actual mechanism.

In this pattern, the shared module defines an expected interface or class that it needs to function. Each platform then provides the actual implementation using its own native libraries and language features. This allows the shared code to remain clean and testable while still having the power to reach down into the hardware when required.

kotlinHardware Access Contract
1// In the common shared module
2expect class DeviceLogger() {
3    fun logEvent(message: String)
4}
5
6// In the iOS implementation module
7actual class DeviceLogger {
8    actual fun logEvent(message: String) {
9        // Uses native iOS os_log
10        println("iOS Native Log: $message")
11    }
12}
13
14// In the Android implementation module
15actual class DeviceLogger {
16    actual fun logEvent(message: String) {
17        // Uses Android Log.d
18        android.util.Log.d("MobileApp", message)
19    }
20}

This mechanism is superior to traditional bridging because it is resolved at compile time. There is no runtime overhead or serialization cost associated with calling into native code. The compiler ensures that if an expected declaration is added, every supported platform provides a corresponding implementation before the build can succeed.

Handling Concurrency and Threading

Concurrency is one of the most challenging aspects of shared logic development due to the different memory models of iOS and Android. While Android uses a standard garbage-collected environment, iOS has historically had stricter requirements for shared state and background threads. Modern multiplatform tools have simplified this, but developers must still be mindful of how they expose asynchronous data.

The most effective strategy is to use reactive streams like Kotlin Flows to emit updates from the shared layer to the UI. On the iOS side, these flows can be wrapped in a way that allows Swift's Combine or Async/Await syntax to consume them naturally. This maintains the native developer experience for the engineers building the presentation layer.

Strategic Implementation and Trade-offs

Transitioning to a logic-first architecture requires a thoughtful evaluation of your team's current expertise and project requirements. It is often most effective for existing native teams who want to increase velocity without retraining everyone on a new UI framework. This approach allows Swift developers to keep writing Swift and Android developers to keep writing Kotlin for their respective views.

However, this paradigm does introduce complexity in the build pipeline and continuous integration process. You must ensure that your CI environment can build for both targets and handle the distribution of the shared library. Additionally, debugging shared code can sometimes be more difficult than debugging pure native code if the sourcemaps are not correctly configured.

  • Reduced code duplication in business, network, and data layers.
  • Full access to native UI components and platform-specific APIs.
  • Consistent behavior across platforms for complex logic.
  • Steeper learning curve for build system configuration.
  • Requires coordination between iOS and Android engineers on shared interfaces.

When deciding whether to adopt this approach, consider the complexity of your business logic. If your app is primarily a simple CRUD interface with very little unique logic, the overhead of a shared module might not be justified. For feature-rich applications with offline modes and complex state management, the logic-first approach provides a massive long-term advantage.

Team Structure for Logic Sharing

Adopting this paradigm often leads to a new team structure where a subset of developers focuses on the shared core while others focus on platform-specific UI. This allows for specialized roles where some engineers become experts in the domain logic and others become masters of the latest platform UI trends like Jetpack Compose or SwiftUI. Such a division of labor can significantly speed up the release of new features.

Successful implementation also requires strong communication regarding the shared API design. Since changes to the shared module can affect both platforms, developers must treat the shared interface as a contract. Versioning the shared library and using thorough integration tests can prevent breaking changes from reaching the UI layers unexpectedly.

We use cookies

Necessary cookies keep the site working. Analytics and ads help us improve and fund Quizzr. You can manage your preferences.