Kotlin Multiplatform — Share Code Between Android & Backend
A practical guide to Kotlin Multiplatform (KMP) — share business logic, models, and validation between your Android app and Spring Boot backend without duplicating code.
You have an Android app and a Spring Boot backend. Both need the same data models. Both validate the same fields. Both parse the same API responses. So you write the same code twice — once in the app, once on the server. When the model changes, you update both. Sometimes you forget one.
Kotlin Multiplatform (KMP) solves this. You write shared code once, and it compiles to both JVM (Spring Boot) and Android. No code generation, no schema files, no runtime overhead — just Kotlin.
KMP has been stable since Kotlin 1.9.20 (November 2023). It’s not experimental anymore.
What you can share
KMP works best for code that has no platform-specific dependencies:
| Share | Don’t share |
|---|---|
| Data models / DTOs | UI code |
| Validation logic | Android Views / Compose |
| Business rules | Spring controllers |
| API client interfaces | Platform-specific storage |
| Serialization | Database access (usually) |
| Date/time logic | Framework-specific code |
The shared module contains pure Kotlin. Platform-specific code stays in each platform’s module.
Project structure
A typical KMP project for Android + backend:
my-project/
├── shared/ # KMP shared module
│ ├── src/
│ │ ├── commonMain/ # shared code
│ │ ├── androidMain/ # Android-specific implementations
│ │ └── jvmMain/ # JVM/backend-specific implementations
│ └── build.gradle.kts
├── android-app/ # Android module
│ └── build.gradle.kts
├── backend/ # Spring Boot module
│ └── build.gradle.kts
├── settings.gradle.kts
└── build.gradle.kts
Setting up the shared module
shared/build.gradle.kts:
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
}
kotlin {
androidTarget()
jvm()
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
Key points:
androidTarget()compiles for Androidjvm()compiles for JVM (Spring Boot)commonMainis where shared code lives- Libraries used in
commonMainmust be multiplatform-compatible
Sharing data models
The most immediate win — define your models once:
shared/src/commonMain/kotlin/com/example/shared/models/User.kt:
package com.example.shared.models
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: String,
val name: String,
val email: String,
val role: UserRole,
val createdAt: String
)
@Serializable
enum class UserRole {
ADMIN, EDITOR, VIEWER
}
shared/src/commonMain/kotlin/com/example/shared/models/ApiResponse.kt:
package com.example.shared.models
import kotlinx.serialization.Serializable
@Serializable
data class ApiResponse<T>(
val data: T?,
val error: ErrorInfo?,
val success: Boolean
)
@Serializable
data class ErrorInfo(
val code: String,
val message: String
)
Now both the Android app and the backend use the exact same User and ApiResponse classes. Change a field once — both platforms see it. Add a new field — both sides get a compile error if they don’t handle it.
Sharing validation logic
Validation is the second biggest win. You validate on the client for UX, and on the server for security. The rules should be identical:
shared/src/commonMain/kotlin/com/example/shared/validation/UserValidation.kt:
package com.example.shared.validation
import com.example.shared.models.User
data class ValidationError(
val field: String,
val message: String
)
fun validateUserRegistration(
name: String,
email: String,
password: String
): List<ValidationError> {
val errors = mutableListOf<ValidationError>()
if (name.isBlank()) {
errors.add(ValidationError("name", "Name is required"))
} else if (name.length < 2 || name.length > 50) {
errors.add(ValidationError("name", "Name must be 2-50 characters"))
}
if (email.isBlank()) {
errors.add(ValidationError("email", "Email is required"))
} else if (!isValidEmail(email)) {
errors.add(ValidationError("email", "Invalid email format"))
}
if (password.length < 8) {
errors.add(ValidationError("password", "Password must be at least 8 characters"))
} else if (!password.any { it.isUpperCase() }) {
errors.add(ValidationError("password", "Password must contain an uppercase letter"))
} else if (!password.any { it.isDigit() }) {
errors.add(ValidationError("password", "Password must contain a digit"))
}
return errors
}
private fun isValidEmail(email: String): Boolean {
return email.contains("@") && email.substringAfter("@").contains(".")
}
On Android:
import com.example.shared.validation.validateUserRegistration
fun onRegisterClicked() {
val errors = validateUserRegistration(
name = binding.etName.text.toString(),
email = binding.etEmail.text.toString(),
password = binding.etPassword.text.toString()
)
if (errors.isNotEmpty()) {
errors.forEach { error ->
when (error.field) {
"name" -> binding.etName.error = error.message
"email" -> binding.etEmail.error = error.message
"password" -> binding.etPassword.error = error.message
}
}
return
}
// proceed with registration
}
On Spring Boot:
import com.example.shared.validation.validateUserRegistration
@PostMapping("/api/users/register")
fun register(@RequestBody request: RegisterRequest): ResponseEntity<Any> {
val errors = validateUserRegistration(
name = request.name,
email = request.email,
password = request.password
)
if (errors.isNotEmpty()) {
return ResponseEntity.badRequest().body(errors)
}
// proceed with registration
}
Same validation, same error messages, same field names. The Android app shows errors instantly. The backend catches anything that slips past.
expect/actual — Platform-specific implementations
Sometimes shared code needs platform-specific behavior. KMP handles this with expect and actual declarations:
shared/src/commonMain/kotlin/com/example/shared/util/Platform.kt:
package com.example.shared.util
expect fun currentTimeMillis(): Long
expect fun generateUuid(): String
shared/src/jvmMain/kotlin/com/example/shared/util/Platform.jvm.kt:
package com.example.shared.util
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
actual fun generateUuid(): String = java.util.UUID.randomUUID().toString()
shared/src/androidMain/kotlin/com/example/shared/util/Platform.android.kt:
package com.example.shared.util
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
actual fun generateUuid(): String = java.util.UUID.randomUUID().toString()
In this case, Android and JVM implementations are identical (both run on JVM). But expect/actual shines when platforms genuinely differ — like logging, file access, or secure storage.
Sharing API constants and routes
Define your API contract in one place:
package com.example.shared.api
object ApiRoutes {
const val BASE = "/api/v1"
object Users {
const val LIST = "$BASE/users"
const val DETAIL = "$BASE/users/{id}"
fun detail(id: String) = "$BASE/users/$id"
}
object Auth {
const val LOGIN = "$BASE/auth/login"
const val REGISTER = "$BASE/auth/register"
const val REFRESH = "$BASE/auth/refresh"
}
}
The backend uses these for route definitions. The Android app uses them for API calls. A renamed endpoint only needs one change.
Sharing business logic
Domain rules that both platforms need:
package com.example.shared.domain
import com.example.shared.models.User
import com.example.shared.models.UserRole
object Permissions {
fun canEdit(user: User, resourceOwnerId: String): Boolean {
return user.role == UserRole.ADMIN || user.id == resourceOwnerId
}
fun canDelete(user: User): Boolean {
return user.role == UserRole.ADMIN
}
fun canInviteUsers(user: User): Boolean {
return user.role == UserRole.ADMIN || user.role == UserRole.EDITOR
}
}
The Android app uses this to show/hide UI elements. The backend uses it to enforce access control. The rules are always in sync.
Consuming the shared module
In Android
android-app/build.gradle.kts:
dependencies {
implementation(project(":shared"))
}
That’s it. The shared module compiles to an Android library. Import and use:
import com.example.shared.models.User
import com.example.shared.validation.validateUserRegistration
import com.example.shared.api.ApiRoutes
In Spring Boot
backend/build.gradle.kts:
dependencies {
implementation(project(":shared"))
implementation("org.springframework.boot:spring-boot-starter-web")
}
Same thing. The shared module compiles to a JVM jar. Import and use the same classes.
Testing shared code
Tests in commonTest run on all platforms:
shared/src/commonTest/kotlin/com/example/shared/validation/UserValidationTest.kt:
package com.example.shared.validation
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class UserValidationTest {
@Test
fun validInputReturnsNoErrors() {
val errors = validateUserRegistration(
name = "Alice",
email = "[email protected]",
password = "Secure123"
)
assertTrue(errors.isEmpty())
}
@Test
fun blankNameReturnsError() {
val errors = validateUserRegistration(
name = "",
email = "[email protected]",
password = "Secure123"
)
assertEquals(1, errors.size)
assertEquals("name", errors[0].field)
}
@Test
fun shortPasswordReturnsError() {
val errors = validateUserRegistration(
name = "Alice",
email = "[email protected]",
password = "Short1"
)
assertEquals(1, errors.size)
assertEquals("password", errors[0].field)
}
@Test
fun invalidEmailReturnsError() {
val errors = validateUserRegistration(
name = "Alice",
email = "not-an-email",
password = "Secure123"
)
assertEquals(1, errors.size)
assertEquals("email", errors[0].field)
}
@Test
fun multipleErrorsReturnedTogether() {
val errors = validateUserRegistration(
name = "",
email = "",
password = "short"
)
assertEquals(3, errors.size)
}
}
Run with ./gradlew :shared:allTests to execute on all configured platforms.
Multiplatform libraries
Not every library works in commonMain. Here are popular ones that do:
| Library | Purpose |
|---|---|
kotlinx-serialization | JSON/Protobuf serialization |
kotlinx-datetime | Date/time operations |
kotlinx-coroutines | Async programming |
ktor-client | HTTP client |
koin | Dependency injection |
multiplatform-settings | Key-value storage |
kotlin-result | Result type |
For platform-specific libraries (Retrofit, Spring Framework, Room), use them in the respective platform source sets, not in commonMain.
When KMP is worth it
Good fit:
- You control both the Android app and the backend
- Models and validation need to stay in sync
- Multiple platforms share business logic
- You’re starting a new project (easier than retrofitting)
Not worth it:
- Backend is in a different language (Python, Go, etc.)
- Shared code is trivial (just a few data classes)
- Team isn’t comfortable with Kotlin on both sides
- You need iOS support and your team doesn’t know KMP (steep learning curve)
Summary
KMP lets you write shared Kotlin code that compiles to Android and JVM. The biggest wins:
- Models — define once, use everywhere
- Validation — same rules on client and server
- Business logic — permissions, calculations, domain rules
- API contracts — routes, constants, error codes
Start small. Move one data model to a shared module. Then validation. Then business logic. You don’t need to go all-in on day one.