PPrepLearn
Progress · 0/6 phases

📁 Phase 2 — Shared Business Logic (Days 13–28)

10 min read · Days 13–28 · Notion

Core insight: The shared business logic layer is where KMP pays off. One Ktor client, one SQLDelight schema, one Koin module, one set of use cases — runs identically on Android, iOS, and Desktop. Every line written here is a line never written twice.


📅 Day 13–15 — Ktor Client: Shared Networking

Why Ktor for KMP

Ktor is the only HTTP client with a genuine KMP implementation. Same API on all targets. Engine is injected per platform via expect/actual.

// commonMain — data/remote/HttpClientFactory.kt
package com.myapp.data.remote
 
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
 
fun createHttpClient(
    engine: HttpClientEngine,
    tokenProvider: () -> String?
): HttpClient = HttpClient(engine) {
 
    install(ContentNegotiation) {
        json(Json {
            ignoreUnknownKeys = true
            isLenient = true
            prettyPrint = false
        })
    }
 
    install(Logging) {
        logger = Logger.DEFAULT
        level = LogLevel.HEADERS
        sanitizeHeader { header -> header == "Authorization" }
    }
 
    install(Auth) {
        bearer {
            loadTokens {
                val token = tokenProvider() ?: return@loadTokens null
                BearerTokens(accessToken = token, refreshToken = "")
            }
            refreshTokens {
                // refresh logic here
                null
            }
        }
    }
 
    install(HttpTimeout) {
        connectTimeoutMillis = 10_000
        requestTimeoutMillis = 30_000
        socketTimeoutMillis = 30_000
    }
 
    install(HttpRequestRetry) {
        retryOnServerErrors(maxRetries = 2)
        exponentialDelay()
    }
 
    defaultRequest {
        url("https://api.myapp.com/v1/")
        headers.append("Accept", "application/json")
    }
}
// expect/actual for engine injection
// commonMain
expect fun createPlatformHttpEngine(): HttpClientEngine
 
// androidMain
actual fun createPlatformHttpEngine(): HttpClientEngine =
    OkHttp.create {
        config {
            retryOnConnectionFailure(true)
            followRedirects(true)
        }
    }
 
// iosMain
actual fun createPlatformHttpEngine(): HttpClientEngine =
    Darwin.create {
        configureRequest {
            setAllowsCellularAccess(true)
            setTimeoutInterval(30.0)
        }
    }
 
// desktopMain
actual fun createPlatformHttpEngine(): HttpClientEngine =
    OkHttp.create()

Remote data sources

// commonMain — data/remote/dto/UserDto.kt
@Serializable
data class UserDto(
    @SerialName("id") val id: String,
    @SerialName("name") val name: String,
    @SerialName("email") val email: String,
    @SerialName("avatar_url") val avatarUrl: String?,
    @SerialName("created_at") val createdAt: String
)
 
@Serializable
data class UserListResponse(
    @SerialName("data") val data: List<UserDto>,
    @SerialName("total") val total: Int,
    @SerialName("page") val page: Int
)
 
// commonMain — data/remote/UserRemoteDataSource.kt
class UserRemoteDataSource(private val client: HttpClient) {
 
    suspend fun getUsers(page: Int = 1): Result<UserListResponse> =
        runCatching {
            client.get("users") {
                parameter("page", page)
                parameter("limit", 20)
            }.body<UserListResponse>()
        }
 
    suspend fun getUserById(id: String): Result<UserDto> =
        runCatching {
            client.get("users/$id").body<UserDto>()
        }
 
    suspend fun createUser(request: CreateUserRequest): Result<UserDto> =
        runCatching {
            client.post("users") {
                contentType(ContentType.Application.Json)
                setBody(request)
            }.body<UserDto>()
        }
 
    suspend fun uploadAvatar(
        userId: String,
        imageBytes: ByteArray,
        fileName: String
    ): Result<UserDto> = runCatching {
        client.submitFormWithBinaryData(
            url = "users/$userId/avatar",
            formData = formData {
                append("avatar", imageBytes, Headers.build {
                    append(HttpHeaders.ContentType, "image/jpeg")
                    append(HttpHeaders.ContentDisposition, "filename=$fileName")
                })
            }
        ).body<UserDto>()
    }
}

Error handling with sealed Result

// commonMain — domain/util/NetworkResult.kt
sealed interface NetworkResult<out T> {
    data class Success<T>(val data: T) : NetworkResult<T>
    data class Error(
        val code: Int? = null,
        val message: String? = null,
        val cause: Throwable? = null
    ) : NetworkResult<Nothing>
    data object Loading : NetworkResult<Nothing>
}
 
// Extension to wrap Ktor exceptions
suspend fun <T> safeApiCall(block: suspend () -> T): NetworkResult<T> {
    return try {
        NetworkResult.Success(block())
    } catch (e: ClientRequestException) {
        NetworkResult.Error(code = e.response.status.value, message = e.message)
    } catch (e: ServerResponseException) {
        NetworkResult.Error(code = e.response.status.value, message = "Server error")
    } catch (e: IOException) {
        NetworkResult.Error(message = "Network unavailable")
    } catch (e: Exception) {
        NetworkResult.Error(message = e.message ?: "Unknown error")
    }
}

📅 Day 16–18 — SQLDelight: Shared Local Database

Why SQLDelight for KMP

SQLDelight generates type-safe Kotlin from SQL. Works on Android (SQLite), iOS (native SQLite), and Desktop (H2/SQLite). Same queries. Same generated API. No ORM magic.

-- shared/src/commonMain/sqldelight/com/myapp/db/User.sq
 
CREATE TABLE User (
    id TEXT NOT NULL PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL,
    avatarUrl TEXT,
    createdAt TEXT NOT NULL,
    updatedAt TEXT NOT NULL,
    isCached INTEGER NOT NULL DEFAULT 0
);
 
CREATE INDEX user_email_idx ON User(email);
 
-- Named queries (SQLDelight generates typed functions for each)
selectAll:
SELECT * FROM User ORDER BY createdAt DESC;
 
selectById:
SELECT * FROM User WHERE id = ?;
 
selectByEmail:
SELECT * FROM User WHERE email = ?;
 
insertOrReplace:
INSERT OR REPLACE INTO User(id, name, email, avatarUrl, createdAt, updatedAt, isCached)
VALUES (?, ?, ?, ?, ?, ?, ?);
 
updateName:
UPDATE User SET name = ?, updatedAt = ? WHERE id = ?;
 
deleteById:
DELETE FROM User WHERE id = ?;
 
deleteAll:
DELETE FROM User;
 
countAll:
SELECT COUNT(*) FROM User;
-- shared/src/commonMain/sqldelight/com/myapp/db/Post.sq
CREATE TABLE Post (
    id TEXT NOT NULL PRIMARY KEY,
    userId TEXT NOT NULL,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    createdAt TEXT NOT NULL,
    FOREIGN KEY (userId) REFERENCES User(id) ON DELETE CASCADE
);
 
selectByUserId:
SELECT * FROM Post WHERE userId = ? ORDER BY createdAt DESC;
 
selectWithUser:
SELECT Post.*, User.name AS authorName, User.avatarUrl AS authorAvatar
FROM Post
INNER JOIN User ON Post.userId = User.id
ORDER BY Post.createdAt DESC;
 
insert:
INSERT INTO Post(id, userId, title, body, createdAt) VALUES (?, ?, ?, ?, ?);

Database driver: expect/actual

// commonMain
expect fun createDatabaseDriver(context: Any? = null): SqlDriver
 
// androidMain
actual fun createDatabaseDriver(context: Any?): SqlDriver =
    AndroidSqliteDriver(
        schema = AppDatabase.Schema,
        context = context as Context,
        name = "app.db"
    )
 
// iosMain
actual fun createDatabaseDriver(context: Any?): SqlDriver =
    NativeSqliteDriver(
        schema = AppDatabase.Schema,
        name = "app.db"
    )
 
// desktopMain
actual fun createDatabaseDriver(context: Any?): SqlDriver =
    JdbcSqliteDriver(
        url = "jdbc:sqlite:app.db",
        properties = Properties().apply { put("foreign_keys", "true") }
    ).also { driver ->
        AppDatabase.Schema.create(driver)
    }

Flow-based repository

// commonMain — data/local/UserLocalDataSource.kt
class UserLocalDataSource(private val db: AppDatabase) {
 
    // Returns a Flow — emits new list whenever DB changes
    fun getAllUsers(): Flow<List<User>> =
        db.userQueries
            .selectAll()
            .asFlow()
            .mapToList(Dispatchers.IO)
            .map { users -> users.map { it.toDomain() } }
 
    fun getUserById(id: String): Flow<User?> =
        db.userQueries
            .selectById(id)
            .asFlow()
            .mapToOneOrNull(Dispatchers.IO)
            .map { it?.toDomain() }
 
    suspend fun upsertUser(user: User) {
        withContext(Dispatchers.IO) {
            db.userQueries.insertOrReplace(
                id = user.id,
                name = user.name,
                email = user.email,
                avatarUrl = user.avatarUrl,
                createdAt = user.createdAt.toString(),
                updatedAt = Clock.System.now().toString(),
                isCached = 1
            )
        }
    }
 
    suspend fun upsertUsers(users: List<User>) {
        withContext(Dispatchers.IO) {
            db.userQueries.transaction {
                users.forEach { user ->
                    db.userQueries.insertOrReplace(
                        id = user.id,
                        name = user.name,
                        email = user.email,
                        avatarUrl = user.avatarUrl,
                        createdAt = user.createdAt.toString(),
                        updatedAt = Clock.System.now().toString(),
                        isCached = 1
                    )
                }
            }
        }
    }
}
 
// Mapper extension
fun com.myapp.db.User.toDomain(): User = User(
    id = id,
    name = name,
    email = email,
    avatarUrl = avatarUrl,
    createdAt = Instant.parse(createdAt)
)

📅 Day 19–21 — Clean Architecture in shared module

shared/commonMain/
  domain/
    model/pure data classes (User, Post, etc.)
    repository/interfaces (UserRepository)
    usecase/single-responsibility business logic
    util/Result, NetworkResult, extensions
  data/
    remote/
      dto/API response shapes
      UserRemoteDataSource.kt
    local/
      UserLocalDataSource.kt
    repository/
      UserRepositoryImpl.kt
  presentation/
    UserListViewModel.kt
    UserDetailViewModel.kt
// domain/model/User.kt
data class User(
    val id: String,
    val name: String,
    val email: String,
    val avatarUrl: String?,
    val createdAt: Instant
)
 
// domain/repository/UserRepository.kt
interface UserRepository {
    fun getUsers(): Flow<List<User>>
    fun getUserById(id: String): Flow<User?>
    suspend fun refreshUsers(): Result<Unit>
    suspend fun createUser(name: String, email: String): Result<User>
}
 
// domain/usecase/GetUsersUseCase.kt
class GetUsersUseCase(private val repository: UserRepository) {
    operator fun invoke(): Flow<List<User>> = repository.getUsers()
}
 
class RefreshUsersUseCase(private val repository: UserRepository) {
    suspend operator fun invoke(): Result<Unit> = repository.refreshUsers()
}
 
// data/repository/UserRepositoryImpl.kt — offline-first
class UserRepositoryImpl(
    private val remote: UserRemoteDataSource,
    private val local: UserLocalDataSource
) : UserRepository {
 
    override fun getUsers(): Flow<List<User>> = local.getAllUsers()
 
    override fun getUserById(id: String): Flow<User?> = local.getUserById(id)
 
    override suspend fun refreshUsers(): Result<Unit> = runCatching {
        val users = remote.getUsers().getOrThrow()
        local.upsertUsers(users.data.map { it.toDomain() })
    }
 
    override suspend fun createUser(name: String, email: String): Result<User> =
        runCatching {
            val dto = remote.createUser(CreateUserRequest(name, email)).getOrThrow()
            val user = dto.toDomain()
            local.upsertUser(user)
            user
        }
}

📅 Day 22–24 — Koin: Dependency Injection in KMP

// commonMain — di/SharedModule.kt
val networkModule = module {
    single { createPlatformHttpEngine() }
    single { createHttpClient(get(), get<TokenProvider>()::getToken) }
    single { UserRemoteDataSource(get()) }
}
 
val databaseModule = module {
    single { createDatabaseDriver(getOrNull()) }
    single { AppDatabase(get()) }
    single { UserLocalDataSource(get()) }
    single { PostLocalDataSource(get()) }
}
 
val repositoryModule = module {
    single<UserRepository> { UserRepositoryImpl(get(), get()) }
    single<PostRepository> { PostRepositoryImpl(get(), get()) }
}
 
val useCaseModule = module {
    factory { GetUsersUseCase(get()) }
    factory { RefreshUsersUseCase(get()) }
    factory { GetUserByIdUseCase(get()) }
    factory { CreateUserUseCase(get()) }
}
 
val viewModelModule = module {
    viewModelOf(::UserListViewModel)
    viewModelOf(::UserDetailViewModel)
    viewModelOf(::CreateUserViewModel)
}
 
fun sharedModules() = listOf(
    networkModule,
    databaseModule,
    repositoryModule,
    useCaseModule,
    viewModelModule
)
// androidMain — di/AndroidModule.kt
val androidModule = module {
    single<TokenProvider> { AndroidTokenProvider(androidContext()) }
    single { createDatabaseDriver(androidContext()) }
}
 
// Android Application class
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@MyApp)
            modules(sharedModules() + androidModule)
        }
    }
}
// iosMain — di/IosModule.kt  
val iosModule = module {
    single<TokenProvider> { IosKeychainTokenProvider() }
}
 
// iOS helper function called from Swift AppDelegate
fun initKoin() {
    startKoin {
        modules(sharedModules() + iosModule)
    }
}

📅 Day 25–27 — Shared ViewModels with KMP-ViewModel

// commonMain — presentation/UserListViewModel.kt
class UserListViewModel(
    private val getUsers: GetUsersUseCase,
    private val refreshUsers: RefreshUsersUseCase
) : ViewModel() {
 
    private val _state = MutableStateFlow(UserListState())
    val state: StateFlow<UserListState> = _state.asStateFlow()
 
    private val _effects = Channel<UserListEffect>(Channel.BUFFERED)
    val effects: Flow<UserListEffect> = _effects.receiveAsFlow()
 
    init {
        observeUsers()
        refresh()
    }
 
    private fun observeUsers() {
        viewModelScope.launch {
            getUsers()
                .distinctUntilChanged()
                .collect { users ->
                    _state.update { it.copy(users = users, isLoading = false) }
                }
        }
    }
 
    fun refresh() {
        viewModelScope.launch {
            _state.update { it.copy(isRefreshing = true) }
            refreshUsers()
                .onFailure { error ->
                    _state.update { it.copy(isRefreshing = false) }
                    _effects.send(UserListEffect.ShowError(error.message ?: "Unknown error"))
                }
                .onSuccess {
                    _state.update { it.copy(isRefreshing = false) }
                }
        }
    }
 
    fun onUserClick(userId: String) {
        viewModelScope.launch {
            _effects.send(UserListEffect.NavigateToDetail(userId))
        }
    }
}
 
data class UserListState(
    val users: List<User> = emptyList(),
    val isLoading: Boolean = true,
    val isRefreshing: Boolean = false,
    val error: String? = null
)
 
sealed interface UserListEffect {
    data class NavigateToDetail(val userId: String) : UserListEffect
    data class ShowError(val message: String) : UserListEffect
}

📅 Day 28 — Phase 2 Project: Full Shared Data Layer

Deliverable: A shared module with the complete data layer for a social app

Features:
User list (remote fetch + SQLDelight cache)
Offline-first: cached data shown immediately, refresh in background
User detail with posts
Create user
Pull-to-refresh
 
Tests:
  ✓ UserRepositoryImpl: mock remote, test offline-first flow with Turbine
  ✓ UserListViewModel: test state transitions on success + error
  ✓ SQLDelight: in-memory driver for fast DB tests
./gradlew :shared:allTests passes on JVM + iOS simulator
// Test example with Turbine + in-memory SQLite
class UserRepositoryTest {
    private lateinit var repo: UserRepositoryImpl
    private lateinit var db: AppDatabase
 
    @BeforeTest
    fun setup() {
        val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
        AppDatabase.Schema.create(driver)
        db = AppDatabase(driver)
        repo = UserRepositoryImpl(
            remote = FakeUserRemoteDataSource(),
            local = UserLocalDataSource(db)
        )
    }
 
    @Test
    fun `getUsers emits cached then fresh data`() = runTest {
        repo.getUsers().test {
            assertEquals(emptyList(), awaitItem())  // initially empty
            repo.refreshUsers()                       // trigger refresh
            val users = awaitItem()
            assertEquals(2, users.size)               // fake returns 2 users
            cancelAndIgnoreRemainingEvents()
        }
    }
}

⚠️ Common mistakes Phase 2

Mistake 1

❌ Using runBlocking in commonMain tests. Kotlin/Native (iOS) doesn’t support runBlocking.

Use runTest from kotlinx-coroutines-test. It’s the only correct way to run coroutine tests in KMP.

Mistake 2

❌ Calling SQLDelight queries on the main thread. iOS crashes with IllegalStateException — SQLite on main thread is forbidden on native.

Always use withContext(Dispatchers.IO) for all DB operations, or use SQLDelight’s asFlow().mapToList(Dispatchers.IO) which handles threading automatically.

Mistake 3

❌ Creating the Ktor HttpClient inside a use case or repository. Client creation is expensive (connection pools, config). Creating one per request is a memory and latency disaster.

Create HttpClient once via Koin single { }. Share it across all data sources.

Related