Building a Kotlin Multiplatform SDK for Jar DigiGold: Lessons from the Trenches
7 min read · Written by Atri Tripathi
One Codebase, Two Platforms, Zero Compromises: Our KMP Journey
At Jar, when we first decided to build a mobile SDK for our digital gold platform, the choice seemed obvious at first: write it separately, write it twice. Once for Android, once for iOS. Different languages, different patterns, different teams.
But then we asked ourselves: what if we didn't have to?
We didn’t want twice the work, twice the maintenance, twice the headaches of building and maintaining separate codebases.
The Multi-Platform Problem
Our digital gold platform had a fairly complex API surface. Users needed to:
- Manage their accounts and KYC verification
- Check live gold prices and execute buy/sell transactions
- Set up recurring purchases (SIPs) via UPI autopay
- View transaction history with pagination
- Handle UPI payments and VPA management
Writing all of this twice meant doubling our surface area for potential bugs, keeping two codebases in sync, and basically guaranteeing that one platform would always lag behind the other. We'd been down that road before, and it wasn't pretty.
Enter Kotlin Multiplatform (KMP).
Why KMP Made Sense (and What We Were Scared Of)
KMP promised us a single source of truth for our business logic, networking, and data models. Write once in Kotlin, compile to native code for both Android and iOS.
KMP enabled a single developer in our team to build a production-grade fintech SDK end-to-end with all the bells and whistles within a span of about two months.
Sounds great, right?
But we had concerns. Big ones.
The iOS elephant in the room: How would Kotlin's suspend functions work in Swift? Swift developers expect async/await, not callback hell. And what about Kotlin's inline Result type that gets erased to Any on the Swift side?
Security: We're dealing with financial transactions here. How do you securely store JWT tokens across platforms? Android has Google Tink and the Keystore, iOS has Keychain. Could we abstract that cleanly?
The web UI situation: We already had a perfectly good web-based UI that our users knew and loved. Building native screens from scratch would take months. Could we somehow embed it in a WebView and bridge it with native functionality?
Spoiler alert: we figured it out. Here's how.
Architecture: Keep It Simple, Keep It Safe
At the heart of our SDK is JarClient, which acts as the entry point to everything:
val client = JarClient {
userId = "user123"
accessToken = "eyJ0eXAiOiJKV1Qi..."
refreshToken = "eyJ0eXAiOiJKV1Qi..."
retryPolicy {
maxRetries = 3
initialDelayMs = 1_000
}
}
// Call any service
when (val result = client.gold.getBuyPrice()) {
is JarResult.Success -> println("Price: ₹${result.value.price}")
is JarResult.Failure -> println("Error: ${result.error}")
}We organized everything into domain-specific services: user, gold, kyc, vpa, autopay, and transaction. Each service is just a clean interface with suspend functions that return a custom JarResult<T> type.
The Result Type Problem
Here's a gotcha we hit early: Kotlin's built-in Result<T> is an inline value class. When it crosses the KMP boundary to Swift, the type information gets erased to Any. Not exactly what you want when you're trying to pattern-match on success vs. failure.
So we rolled our own:
sealed interface JarResult<out T> {
data class Success<T>(val value: T) : JarResult<T>
data class Failure(val error: JarError) : JarResult<Nothing>
}Simple, explicit, and it preserves full type information on iOS. Problem solved.
Making Swift Feel Native
We use [SKIE](https://github.com/touchlab/SKIE) from Touchlab to make our Kotlin suspend functions surface as Swift async/await. This was a game-changer:
// Feels completely native to Swift developers
Task {
do {
let price = try await sdk.gold.getBuyPriceOrThrow().price
print("Buy at ₹\(price)")
} catch {
print("Error: \(error)")
}
}No callbacks, no completion handlers, just idiomatic Swift async code. SKIE handles all the bridging magic for us.
Security: Not All Platforms Are Created Equal
Storing authentication tokens securely was non-negotiable. We needed AES-GCM encryption backed by hardware-protected keystores where available.
Here's our abstraction:
internal interface SecureStorage {
suspend fun read(key: String): ByteArray?
suspend fun write(key: String, data: ByteArray)
suspend fun delete(key: String)
}
internal expect object SecureStorageFactory {
fun default(): SecureStorage
}On Android, we use Google Tink with the Android Keystore:
val keysetHandle = AndroidKeysetManager.Builder()
.withSharedPref(context, "jar_secure_prefs", "jar_secure_keyset")
.withKeyTemplate(KeyTemplates.get("AES256_GCM"))
.withMasterKeyUri("android-keystore://jar_sdk_master_key")
.build()
.keysetHandle
val aead: Aead = keysetHandle.getPrimitive(Aead::class.java)On iOS, we go straight to the Keychain. Different platforms, same simple interface. The rest of our SDK doesn't need to know or care about the platform-specific details.
The WebView Bridge: Best of Both Worlds
Here's where things got interesting. We had a fully-featured web UI as part of our headful sdk-ui module, but we needed native integration for things like:
- Fetching user details from the SDK
- Retrieving auth tokens
- Launching UPI payment apps
- Opening device camera for KYC
Rather than rebuilding everything in native UI, we built a JavaScript bridge.
The web UI posts JSON messages like this:
// From JavaScript
window.JarNative.postMessage(JSON.stringify({
id: '1',
action: 'getUser'
}));Our native bridge handles it in Kotlin:
when (request.action) {
GET_USER -> client.user.getUserDetails().fold(
onSuccess = { user ->
BridgeResponse(
id = request.id,
success = true,
payload = Json.encodeToJsonElement(user)
)
},
onFailure = { err ->
errorResponse(
id = request.id,
code = BridgeErrorCodes.SDK_ERROR,
message = err.message ?: err.toString()
)
}
)
}The bridge supports actions like handshake, getUser, getTokens, getUpiApps, openDeeplink, pickImage, and close. Each action is validated, executed, and returns a typed response.
We added security guardrails:
- Navigation allowlist to prevent redirect attacks
- Mixed-content blocking
- Safe token injection (JSON-escaped into localStorage)
- Safe Browsing where platform-supported
The web team can iterate on UI/UX independently, while we control the native integration points. Win-win.
The Challenges: What We Didn't See Coming
Memory Monsters
Our first CI builds? Complete disasters. We kept hitting OutOfMemoryError: Java heap space during the Kotlin/Native compilation phase. Turns out, compiling iOS frameworks (both iosArm64 and iosSimulatorArm64 targets) is memory-intensive.
We had to explore around and bump our JVM heap allocation size:
# gradle.properties
org.gradle.jvmargs=-Xmx8192M -XX:MaxMetaspaceSize=1g
# GitHub Actions CI
GRADLE_OPTS: -Xmx8g -XX:+HeapDumpOnOutOfMemoryErrorThe culprit? Our sdk-ui module exports the entire sdk module plus uses SKIE for Swift interoperability. That's a lot of code to compile and optimize. The analysis phase of the Kotlin/Native compiler was particularly hungry.
Xcode Integration Gotchas
To test things locally on iOS, every time we made changes to shared Kotlin code, the iOS XCFramework had to be rebuilt:
./gradlew clean publishToMavenLocal
./gradlew spmDevBuild
If you forgot this step, you'd be staring at stale code in your iOS app wondering why your changes weren't working. We learned to add this to our development workflow documentation prominently.
Also, XCode IDE is already notorious for this, but every new Swift files created outside of Xcode don't automatically get added to the build phases. You have to manually add them via Xcode's UI. This tripped us up more times than we'd like to admit.
Type Safety Across the Void
Getting pagination to work cleanly on iOS required some creativity. We exposed Kotlin Flows as Swift AsyncSequence:
let flow = TransactionPagingExtensionsKt.listTransactionsAsFlow(
sdk.transaction,
pageSize: 10,
status: "",
type: ""
)
for try await transaction in flow.asyncStream(TransactionDetails.self) {
print("Transaction: \(transaction.transactionId)")
}SKIE does the heavy lifting here, but we still had to design our APIs carefully to ensure they felt natural on both platforms.
What We Learned
1. KMP is production-ready, but know your tools
The ecosystem has matured significantly. Ktor for networking, kotlinx.serialization for JSON, SKIE for Swift interop—these tools work beautifully together. But you need to understand their limitations and quirks.
2. Abstract early, abstract often
Platform differences exist. Embrace them through clean abstractions (like our SecureStorage interface) rather than fighting them with conditional compilation everywhere.
3. Don't rebuild what you don't have to
Our WebView bridge approach let us ship faster while maintaining a consistent user experience. Sometimes the hybrid approach is the pragmatic choice.
4. CI memory configuration matters
If you're building iOS frameworks in CI, budget for memory. A lot of it. And make sure your local gradle.properties matches your CI environment.
5. Documentation is code
We built our docs with Docusaurus + Dokka, versioning them alongside code releases. Outdated docs cause more support issues than bugs.
6. Test both platforms religiously
It's "write once, run anywhere" for business logic, but platform-specific behaviours (especially around UI, storage, and permissions) need thorough testing on both sides.
The Results
After all the challenges, what did we end up with?
SDK Size:
- Android: ~617 KB
- iOS: 33.2 MB (iOS frameworks are larger due to how Apple packages binaries, but the actual code footprint is minimal)
Developer Experience: Our sample apps show the SDK in action on both platforms. Android uses Jetpack Compose, iOS uses SwiftUI. Both feel native because they are native.
Maintenance: One codebase for core business logic. When we add a new API endpoint or fix a networking bug, both platforms get it. Instantly.
Type Safety: No more "stringly-typed" APIs or weakly-typed JSON. Everything is strongly typed from the API layer down to the network models.
Security: Hardware-backed encryption on both platforms, with zero compromise.
Closing Thoughts
Building a Kotlin Multiplatform SDK wasn't the easy path. We hit memory walls, fought with build systems, and spent more time than we'd like to admit figuring out why Xcode couldn't find our Swift files.
But the payoff? Absolutely worth it.
We now ship features simultaneously to both platforms. Our API surface is consistent because there's only one source of truth. And most importantly, our developers (both internal and external) have a better experience.
If you're on the fence about KMP for your SDK or library, here's my advice: start small. Build a proof-of-concept with your core domain models and one API service. See how it feels. Kick the tires on the Swift interop. Check your build times and memory usage.
KMP isn't a silver bullet, but for the right use case—like a mobile SDK with complex business logic and a need for platform parity—it's pretty damn close.
Questions? Feel free to reach out to us. We're always happy to talk shop about KMP, mobile SDKs, or building secure fintech platforms.