PCSalt
YouTube GitHub
Back to Kotlin
Kotlin · 3 min read

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:

ShareDon’t share
Data models / DTOsUI code
Validation logicAndroid Views / Compose
Business rulesSpring controllers
API client interfacesPlatform-specific storage
SerializationDatabase access (usually)
Date/time logicFramework-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 Android
  • jvm() compiles for JVM (Spring Boot)
  • commonMain is where shared code lives
  • Libraries used in commonMain must 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:

LibraryPurpose
kotlinx-serializationJSON/Protobuf serialization
kotlinx-datetimeDate/time operations
kotlinx-coroutinesAsync programming
ktor-clientHTTP client
koinDependency injection
multiplatform-settingsKey-value storage
kotlin-resultResult 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:

  1. Models — define once, use everywhere
  2. Validation — same rules on client and server
  3. Business logic — permissions, calculations, domain rules
  4. 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.