KMP Best Practices
BrewKits' recommended best practices for Kotlin Multiplatform development.
Project Structure
1. Organize by Feature
src/
├── commonMain/
│ └── kotlin/
│ └── dev/brewkits/library/
│ ├── auth/
│ │ ├── AuthService.kt
│ │ ├── AuthRepository.kt
│ │ └── models/
│ ├── network/
│ │ ├── ApiClient.kt
│ │ └── models/
│ └── utils/
├── androidMain/
│ └── kotlin/
│ └── dev/brewkits/library/
│ ├── auth/
│ │ └── AuthService.android.kt
│ └── network/
│ └── ApiClient.android.kt
└── iosMain/
└── kotlin/
└── dev/brewkits/library/
├── auth/
│ └── AuthService.ios.kt
└── network/
└── ApiClient.ios.kt
2. Shared Models in commonMain
// commonMain
data class User(
val id: String,
val name: String,
val email: String,
val createdAt: Long
)
data class ApiResponse<T>(
val data: T?,
val error: String?,
val isSuccess: Boolean
)
Code Sharing Strategies
1. Maximize Common Code
Put as much as possible in commonMain:
// ✅ Good - pure Kotlin in commonMain
class UserValidator {
fun validateEmail(email: String): Boolean {
val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".toRegex()
return email.matches(emailRegex)
}
fun validatePassword(password: String): Boolean {
return password.length >= 8
}
}
// ✅ Good - business logic shared
class AuthRepository(
private val apiClient: ApiClient,
private val storage: SecureStorage
) {
suspend fun login(email: String, password: String): Result<User> {
return try {
val response = apiClient.post("/auth/login", mapOf(
"email" to email,
"password" to password
))
storage.saveToken(response.token)
Result.success(response.user)
} catch (e: Exception) {
Result.failure(e)
}
}
}
2. Use expect/actual for Platform APIs
// commonMain
expect class SecureStorage {
fun saveToken(token: String)
fun getToken(): String?
fun clearToken()
}
// androidMain
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
actual class SecureStorage(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
actual fun saveToken(token: String) {
prefs.edit().putString("auth_token", token).apply()
}
actual fun getToken(): String? {
return prefs.getString("auth_token", null)
}
actual fun clearToken() {
prefs.edit().remove("auth_token").apply()
}
}
// iosMain
import platform.Foundation.NSUserDefaults
actual class SecureStorage {
private val keychain = /* Keychain wrapper */
actual fun saveToken(token: String) {
keychain.set(token, forKey = "auth_token")
}
actual fun getToken(): String? {
return keychain.string(forKey = "auth_token")
}
actual fun clearToken() {
keychain.delete(forKey = "auth_token")
}
}
3. Interfaces Over expect/actual
Prefer interfaces when possible:
// commonMain
interface Logger {
fun log(message: String)
fun error(message: String, throwable: Throwable?)
}
class DefaultLogger : Logger {
override fun log(message: String) {
println(message)
}
override fun error(message: String, throwable: Throwable?) {
println("ERROR: $message")
throwable?.printStackTrace()
}
}
// Dependency injection
class UserService(
private val logger: Logger = DefaultLogger()
) {
fun processUser(user: User) {
logger.log("Processing user: ${user.name}")
}
}
Concurrency
1. Use Coroutines
class DataRepository(
private val api: ApiClient,
private val cache: Cache
) {
suspend fun fetchData(): Result<Data> = withContext(Dispatchers.IO) {
try {
// Try cache first
cache.get("data")?.let { cached ->
return@withContext Result.success(cached)
}
// Fetch from network
val response = api.getData()
cache.set("data", response)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
fun observeData(): Flow<Data> = flow {
while (true) {
val data = fetchData().getOrNull()
data?.let { emit(it) }
delay(5000) // Poll every 5 seconds
}
}.flowOn(Dispatchers.IO)
}
2. StateFlow for State Management
class UserViewModel {
private val _userState = MutableStateFlow<UserState>(UserState.Initial)
val userState: StateFlow<UserState> = _userState.asStateFlow()
fun login(email: String, password: String) {
viewModelScope.launch {
_userState.value = UserState.Loading
val result = authRepository.login(email, password)
_userState.value = when {
result.isSuccess -> UserState.Success(result.getOrNull()!!)
else -> UserState.Error(result.exceptionOrNull()?.message)
}
}
}
}
sealed class UserState {
object Initial : UserState()
object Loading : UserState()
data class Success(val user: User) : UserState()
data class Error(val message: String?) : UserState()
}
Networking
1. Use Ktor for HTTP
// commonMain
class ApiClient {
private val client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO
}
defaultRequest {
url("https://api.brewkits.dev/v1/")
header(HttpHeaders.ContentType, ContentType.Application.Json)
}
}
suspend fun <T> get(
endpoint: String,
responseType: KClass<T>
): T {
return client.get(endpoint).body()
}
suspend fun <T> post(
endpoint: String,
body: Any,
responseType: KClass<T>
): T {
return client.post(endpoint) {
setBody(body)
}.body()
}
}
Data Persistence
1. Use SQLDelight
// commonMain
class UserDatabase(driver: SqlDriver) {
private val database = Database(driver)
fun insertUser(user: User) {
database.userQueries.insert(
id = user.id,
name = user.name,
email = user.email
)
}
fun getUser(id: String): User? {
return database.userQueries.selectById(id).executeAsOneOrNull()?.let {
User(
id = it.id,
name = it.name,
email = it.email
)
}
}
fun observeUsers(): Flow<List<User>> {
return database.userQueries.selectAll()
.asFlow()
.mapToList()
.map { list -> list.map { /* map to User */ } }
}
}
// androidMain
actual class DatabaseDriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(
Database.Schema,
context,
"brewkits.db"
)
}
}
// iosMain
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver {
return NativeSqliteDriver(
Database.Schema,
"brewkits.db"
)
}
}
Testing
1. Shared Tests in commonTest
class UserValidatorTest {
private val validator = UserValidator()
@Test
fun `valid email should return true`() {
assertTrue(validator.validateEmail("test@brewkits.dev"))
}
@Test
fun `invalid email should return false`() {
assertFalse(validator.validateEmail("invalid-email"))
}
@Test
fun `password with 8+ chars should be valid`() {
assertTrue(validator.validatePassword("password123"))
}
}
2. Mock Platform Dependencies
class MockSecureStorage : SecureStorage {
private val storage = mutableMapOf<String, String>()
override fun saveToken(token: String) {
storage["auth_token"] = token
}
override fun getToken(): String? {
return storage["auth_token"]
}
override fun clearToken() {
storage.remove("auth_token")
}
}
class AuthRepositoryTest {
private val mockStorage = MockSecureStorage()
private val mockApi = MockApiClient()
private val repository = AuthRepository(mockApi, mockStorage)
@Test
fun `login should save token`() = runTest {
val result = repository.login("test@brewkits.dev", "password123")
assertTrue(result.isSuccess)
assertNotNull(mockStorage.getToken())
}
}
Dependency Injection
Use Koin
// commonMain
val appModule = module {
single<Logger> { DefaultLogger() }
single { ApiClient() }
single { SecureStorage() }
single { AuthRepository(get(), get()) }
factory { UserViewModel(get()) }
}
// Initialize in app
fun initKoin() {
startKoin {
modules(appModule)
}
}
// Usage
class MyScreen {
private val viewModel: UserViewModel by inject()
}
Performance
1. Lazy Initialization
class HeavyResource {
companion object {
val instance: HeavyResource by lazy {
HeavyResource().apply {
// Expensive initialization
}
}
}
}
2. Optimize expect/actual
// ❌ Bad - separate expect/actual for each function
expect fun getCurrentTime(): Long
expect fun formatTime(time: Long): String
expect fun parseTime(str: String): Long
// ✅ Good - single interface
expect class TimeUtil {
fun getCurrentTime(): Long
fun formatTime(time: Long): String
fun parseTime(str: String): Long
}
Documentation
KDoc Comments
/**
* Manages user authentication and authorization.
*
* This repository handles login, logout, and token management
* across all supported platforms.
*
* @property apiClient The API client for network requests
* @property storage Secure storage for tokens
*
* @constructor Creates an AuthRepository with the given dependencies
*/
class AuthRepository(
private val apiClient: ApiClient,
private val storage: SecureStorage
) {
/**
* Authenticates a user with email and password.
*
* @param email User's email address
* @param password User's password
* @return Result containing the authenticated User or an error
*
* @throws NetworkException if network request fails
*/
suspend fun login(email: String, password: String): Result<User> {
// Implementation
}
}
Resources
Build robust, maintainable KMP libraries!