Skip to Content
Course content

81: Sharing Business Logic Across Platforms

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I was chatting with the iOS lead on my last project, and they were complaining—loudly—that their subscription pricing logic was slightly off compared to the Android app. Specifically, the way we handled the "Annual Discount" calculation was diverging. I looked at the Android code, then the Swift code, and it was a mess. Two different implementations of the same business rule. That's a recipe for customer support nightmares.

Moving the Logic into the Common Bucket

My first instinct was to just rip the logic out of the Android app and shove it into a Kotlin Multiplatform (KMP) shared module. I created a simple SubscriptionCalculator class. I wanted something that takes a base price, a tier, and returns the final cost.

// In commonMain
class SubscriptionCalculator {
    fun calculateYearlyPrice(monthlyPrice: Double, discountPercent: Double): Double {
        val annualBase = monthlyPrice * 12
        return annualBase * (1.0 - (discountPercent / 100.0))
    }
}

I compiled it, hooked it up to the Android app, and it worked perfectly. Then I pointed the iOS framework at it. To my surprise, it also worked. For pure mathematical logic—stuff that doesn't touch a screen, a database, or a file system—KMP is almost invisible. I just write Kotlin, and it works everywhere. But then I tried to make the output user-friendly.

The Moment the Compiler Screamed

I figured I'd add a helper method to format the price as a currency string. I'm used to JVM development, so I instinctively reached for java.text.NumberFormat. I added this to my shared class:

// Still in commonMain
fun formatPrice(price: Double): String {
    return java.text.NumberFormat.getCurrencyInstance().format(price)
}

The IDE immediately turned red. The compiler started throwing a fit, telling me that java.text isn't available in the common source set. It makes sense—iOS doesn't know what a JVM is. I can't use Java libraries in commonMain because that code is intended to be compiled to both JVM bytecode and native LLVM binaries.

I could have just moved the formatting back to the platform-specific UI layers, but that feels like a cop-out. I wanted the logic of how we represent currency to be shared, even if the mechanism for doing it is different on each OS.

Bridging the Gap with Expect and Actual

This is where I had to pivot. I can't use java.text, but I can tell Kotlin: "I expect there to be a way to format currency, and I'll provide the specific implementation for each platform."

First, I defined the "expectation" in commonMain. I didn't write a body for the function; I just defined the signature:

// In commonMain
expect fun formatCurrency(amount: Double): String

Now the shared SubscriptionCalculator can actually use that function without knowing how it works under the hood. But the project won't compile until I fulfill that promise for every target platform. I hopped over to the Android (jvmMain) source set and wrote the "actual" implementation:

// In androidMain
actual fun formatCurrency(amount: Double): String {
    return java.text.NumberFormat.getCurrencyInstance().format(amount)
}

Then, I switched to the iOS (iosMain) source set. Since I'm in Kotlin, I can actually call Apple's NSNumberFormatter directly through the interop layer:

// In iosMain
import platform.Foundation.NSNumberFormatter
import platform.Foundation.NSNumberFormatterCurrencyStyle

actual fun formatCurrency(amount: Double): String {
    val formatter = NSNumberFormatter()
    formatter.numberStyle = NSNumberFormatterCurrencyStyle
    return formatter.stringFromNumber(amount.toNSNumber()) ?: "$$amount"
}

Now, when I call calculator.formatPrice(price) in the shared module, Kotlin handles the dispatch. On Android, it runs the Java code; on iOS, it runs the Foundation framework code. We've shared the intent and the orchestration of the business logic, while respecting the reality that platforms have different tools for the same job.




📋 Practical Task

Implement a Cross-Platform Validation Engine

You are building a shared validation library for a user registration flow. You need to ensure that a username is valid across both Android and iOS, but the "strictness" of the validation (specifically, checking for forbidden characters) needs to use a platform-specific Regular Expression engine for performance reasons.

  • In commonMain:
    • Create a class UserValidator.
    • Define an expect fun isUsernameAllowed(username: String): Boolean.
    • Create a shared method validateRegistration(username: String) that returns a String: "Invalid Length" if the username is under 3 characters, and "Forbidden Characters" if isUsernameAllowed returns false. Otherwise, return "Valid".
  • In androidMain:
    • Implement the actual fun isUsernameAllowed using a Java Regex that returns true only if the string contains alphanumeric characters.
  • In iosMain:
    • Implement the actual fun isUsernameAllowed using a simple Kotlin all { it.isLetterOrDigit() } check (simulating the native implementation).
Rating
0 0

There are no comments for now.

to be the first to leave a comment.