Cross-Platform Architecture: Sharing Logic Between iOS and Android

How Klivvr structures shared business logic across iOS and Android using Kotlin Multiplatform, and the architectural decisions that make native-plus-shared a practical reality.

technical9 min readBy Klivvr Engineering
Share:

Every mobile engineering team building for both iOS and Android eventually confronts the same question: how much code can we share, and at what cost? The spectrum of answers ranges from "nothing — two fully native codebases" to "everything — a single cross-platform framework." Neither extreme serves a fintech product well. Pure duplication doubles the surface area for bugs in critical financial logic. Full cross-platform abstraction sacrifices the native feel that users expect from an app they trust with their money.

At Klivvr, we landed on a pragmatic middle ground: native UI on each platform, with shared business logic powered by Kotlin Multiplatform (KMP). This article walks through the architecture, the tradeoffs, and the practical patterns that make this approach work at scale.

Why Not a Full Cross-Platform Framework?

Before explaining what we chose, it is worth explaining what we did not choose and why. React Native, Flutter, and similar frameworks offer a compelling pitch: one codebase, two platforms. For many product categories, this is the right call. For a banking app, we identified three concerns that steered us away:

Platform-native security APIs. iOS and Android have fundamentally different approaches to biometric authentication, secure storage (Keychain vs. KeyStore), and certificate handling. Wrapping these in a cross-platform abstraction means either accepting a lowest-common-denominator security posture or building complex platform-specific bridges — at which point the "one codebase" benefit erodes.

Regulatory and compliance constraints. Financial regulators in some jurisdictions require specific platform-level behaviors (e.g., screen-capture prevention, jailbreak detection). These are deeply tied to platform APIs and are difficult to implement correctly through a bridge layer.

User expectations for fidelity. Banking apps sit alongside system apps like Settings, Messages, and Wallet. Users expect the navigation, animations, and interaction patterns to match the platform. A banking app that "feels like a website in an app shell" undermines the trust we are trying to build.

That said, we had no interest in writing our networking layer, data transformation logic, analytics event definitions, or business rules twice. The duplication risk in a fintech context is severe: if the iOS app computes a fee differently than the Android app due to a logic divergence, the consequences range from customer complaints to regulatory violations.

The Shared-Logic Architecture

Our architecture splits into three layers:

  1. Shared layer (Kotlin Multiplatform). Business logic, data models, networking, and validation rules. This code compiles to a framework consumed by iOS (via Kotlin/Native) and to standard Kotlin consumed by Android.

  2. Platform layer (Swift / Kotlin). UI, navigation, platform-specific integrations (biometrics, push notifications, secure storage), and accessibility.

  3. Bridge layer. A thin interface that connects the shared layer to platform-specific capabilities. This is where we define expect/actual declarations and platform-specific dependency injection.

The shared module lives in a dedicated Gradle module. A simplified project structure looks like this:

shared/
  src/
    commonMain/    # Shared Kotlin code
    iosMain/       # iOS-specific actual declarations
    androidMain/   # Android-specific actual declarations
iosApp/            # Xcode project consuming the shared framework
androidApp/        # Android app module

The commonMain source set contains the vast majority of shared code. Here is an example of a shared data model and a use case:

// shared/src/commonMain/kotlin/com/klivvr/model/Transaction.kt
data class Transaction(
    val id: String,
    val amount: Money,
    val merchant: String,
    val category: TransactionCategory,
    val timestamp: Instant,
    val status: TransactionStatus
)
 
data class Money(
    val amount: Long,       // Amount in minor units (e.g., cents)
    val currency: Currency
) {
    fun formatted(): String {
        val major = amount / currency.minorUnitScale
        val minor = amount % currency.minorUnitScale
        return "${currency.symbol}$major.${minor.toString().padStart(currency.decimalPlaces, '0')}"
    }
}
// shared/src/commonMain/kotlin/com/klivvr/usecase/GetTransactionHistoryUseCase.kt
class GetTransactionHistoryUseCase(
    private val transactionRepository: TransactionRepository,
    private val currencyConverter: CurrencyConverter
) {
    suspend fun execute(
        accountId: String,
        displayCurrency: Currency,
        page: Int,
        pageSize: Int = 20
    ): Result<List<TransactionDisplayItem>> {
        return transactionRepository
            .getTransactions(accountId, page, pageSize)
            .map { transactions ->
                transactions.map { tx ->
                    TransactionDisplayItem(
                        id = tx.id,
                        merchantName = tx.merchant,
                        displayAmount = currencyConverter
                            .convert(tx.amount, displayCurrency)
                            .formatted(),
                        category = tx.category,
                        formattedDate = tx.timestamp.formatRelative(),
                        status = tx.status
                    )
                }
            }
    }
}

This use case runs identically on both platforms. The repository and converter are injected, and their implementations may differ per platform where necessary (though in practice, our networking layer is also shared).

Networking and Serialization

The networking layer is one of the highest-value components to share. We use Ktor as the HTTP client, which supports both iOS and Android targets natively. Combined with kotlinx.serialization for JSON parsing, the entire API communication layer lives in commonMain.

// shared/src/commonMain/kotlin/com/klivvr/network/KlivvrApiClient.kt
class KlivvrApiClient(
    private val httpClient: HttpClient,
    private val tokenProvider: TokenProvider
) {
    suspend fun getAccounts(): Result<List<Account>> {
        return runCatching {
            httpClient.get("${BASE_URL}/v1/accounts") {
                header("Authorization", "Bearer ${tokenProvider.getAccessToken()}")
                header("X-Request-Id", generateRequestId())
            }.body<AccountsResponse>().accounts
        }
    }
 
    suspend fun initiateTransfer(request: TransferRequest): Result<TransferResponse> {
        return runCatching {
            httpClient.post("${BASE_URL}/v1/transfers") {
                header("Authorization", "Bearer ${tokenProvider.getAccessToken()}")
                header("X-Idempotency-Key", request.idempotencyKey)
                contentType(ContentType.Application.Json)
                setBody(request)
            }.body<TransferResponse>()
        }
    }
}

The TokenProvider is defined as an expect declaration in commonMain and implemented differently on each platform, since iOS stores tokens in the Keychain and Android uses the EncryptedSharedPreferences backed by the KeyStore:

// shared/src/commonMain/kotlin/com/klivvr/auth/TokenProvider.kt
expect class TokenProvider {
    suspend fun getAccessToken(): String
    suspend fun getRefreshToken(): String
    suspend fun storeTokens(access: String, refresh: String)
    suspend fun clearTokens()
}
// shared/src/androidMain/kotlin/com/klivvr/auth/TokenProvider.kt
actual class TokenProvider(private val context: Context) {
    private val prefs = EncryptedSharedPreferences.create(
        "klivvr_tokens",
        MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
        context,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )
 
    actual suspend fun getAccessToken(): String =
        prefs.getString("access_token", null)
            ?: throw AuthenticationException("No access token")
 
    // ... remaining implementations
}

On the iOS side, the actual implementation wraps the Keychain:

// In the iOS app, the shared framework exposes TokenProvider.
// The iOS-specific actual declaration in iosMain wraps Keychain access.
// shared/src/iosMain/kotlin/com/klivvr/auth/TokenProvider.kt
actual class TokenProvider {
    private val keychain = KeychainWrapper(service: "com.klivvr.tokens")
 
    actual suspend fun getAccessToken(): String =
        keychain.get("access_token")
            ?: throw AuthenticationException("No access token")
 
    actual suspend fun storeTokens(access: String, refresh: String) {
        keychain.set("access_token", access, accessibility: .whenUnlockedThisDeviceOnly)
        keychain.set("refresh_token", refresh, accessibility: .whenUnlockedThisDeviceOnly)
    }
 
    // ... remaining implementations
}

Consuming Shared Code from Swift

One of the practical challenges with KMP is the Swift interop layer. Kotlin/Native compiles to an Objective-C framework, which Swift can consume, but the API surface can feel unidiomatic. We address this with a thin Swift wrapper layer that translates shared types into Swift-native patterns.

// iOS/Sources/Repositories/TransactionRepository.swift
import SharedFramework
 
class TransactionRepositoryBridge: ObservableObject {
    private let useCase: GetTransactionHistoryUseCase
 
    @Published var transactions: [TransactionDisplayItem] = []
    @Published var isLoading: Bool = false
    @Published var error: String? = nil
 
    init(useCase: GetTransactionHistoryUseCase) {
        self.useCase = useCase
    }
 
    func loadTransactions(accountId: String, page: Int) {
        isLoading = true
        error = nil
 
        Task {
            do {
                let result = try await asyncResult {
                    useCase.execute(
                        accountId: accountId,
                        displayCurrency: .usd,
                        page: Int32(page),
                        pageSize: 20
                    )
                }
                await MainActor.run {
                    self.transactions = result
                    self.isLoading = false
                }
            } catch {
                await MainActor.run {
                    self.error = error.localizedDescription
                    self.isLoading = false
                }
            }
        }
    }
}

The asyncResult helper bridges Kotlin coroutines to Swift concurrency. The SKIE library from Touchlab significantly improves this interop by generating Swift-friendly async wrappers automatically, and we recommend it for any team adopting KMP for iOS.

On the Android side, consumption is seamless since the shared module is standard Kotlin:

// androidApp/src/main/kotlin/com/klivvr/ui/transactions/TransactionViewModel.kt
class TransactionViewModel(
    private val getTransactionHistory: GetTransactionHistoryUseCase
) : ViewModel() {
 
    private val _uiState = MutableStateFlow(TransactionUiState())
    val uiState: StateFlow<TransactionUiState> = _uiState.asStateFlow()
 
    fun loadTransactions(accountId: String, page: Int) {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true, error = null) }
            getTransactionHistory
                .execute(accountId, Currency.USD, page)
                .onSuccess { items ->
                    _uiState.update { it.copy(transactions = items, isLoading = false) }
                }
                .onFailure { error ->
                    _uiState.update { it.copy(error = error.message, isLoading = false) }
                }
        }
    }
}

Testing the Shared Layer

One of the strongest arguments for shared logic is shared tests. Business rules tested once apply to both platforms:

// shared/src/commonTest/kotlin/com/klivvr/model/MoneyTest.kt
class MoneyTest {
    @Test
    fun `formats USD correctly`() {
        val money = Money(amount = 1050, currency = Currency.USD)
        assertEquals("$10.50", money.formatted())
    }
 
    @Test
    fun `formats zero minor units correctly`() {
        val money = Money(amount = 1000, currency = Currency.USD)
        assertEquals("$10.00", money.formatted())
    }
 
    @Test
    fun `formats currency with three decimal places`() {
        val money = Money(amount = 1500, currency = Currency.KWD)
        assertEquals("KD1.500", money.formatted())
    }
}

These tests run on both the JVM (for fast local iteration) and on Kotlin/Native (to verify iOS behavior). A single ./gradlew :shared:allTests command validates the logic for both targets.

Practical Tradeoffs and Lessons Learned

After two years of running this architecture in production, here are the unvarnished lessons:

Build times are the biggest pain point. Kotlin/Native compilation for iOS is slower than pure Swift compilation. We mitigate this by structuring the shared module into sub-modules and caching aggressively with Gradle build cache and CI artifact caching. Incremental builds are manageable; clean builds require patience.

Keep the shared layer purely logic. The moment you try to share UI code, view models with platform-specific lifecycle awareness, or threading primitives, the complexity spikes. Our rule is simple: if it touches a screen, it is platform code. If it transforms data or enforces a business rule, it is shared code.

Version the shared framework carefully. The iOS team consumes the shared module as a prebuilt XCFramework in CI. We version it with semantic versioning and maintain a changelog. Breaking changes in the shared API trigger a coordinated release across both platforms.

Invest in tooling. We built a Gradle plugin that auto-generates Swift-friendly wrappers for shared enums and sealed classes. We also built a linting rule that prevents accidental platform-specific imports in commonMain. These investments paid for themselves within weeks.

Conclusion

Cross-platform architecture for a fintech mobile app is not a binary choice between "fully native" and "fully shared." The practical sweet spot — native UI with shared business logic — delivers the reliability of tested-once financial rules alongside the polish of platform-native interactions. Kotlin Multiplatform is not a silver bullet; it introduces build complexity, demands interop expertise, and requires discipline to keep the shared boundary clean. But for a team shipping a banking app on two platforms where correctness of financial logic is non-negotiable, the investment is well worth it. The alternative — maintaining two independent implementations of fee calculations, currency conversions, and transaction validation — is a risk no fintech team should accept willingly.

Related Articles

business

Reducing Friction in Fintech User Onboarding

Strategies and technical approaches for streamlining fintech user onboarding, from identity verification and KYC to progressive profiling, while balancing regulatory compliance with conversion optimization.

11 min read
business

Layered Security Architecture for Mobile Banking

An in-depth look at the multi-layered security architecture that protects mobile banking apps, from device integrity checks and encrypted storage to runtime protection and network security.

9 min read
business

App Store Optimization for Fintech Products

A comprehensive guide to App Store Optimization (ASO) for fintech mobile apps, covering keyword strategy, creative optimization, ratings management, and the unique challenges of marketing a financial product in competitive app stores.

9 min read