🧠 Room Database with koin Integration in a Kotlin Multiplatform (KMP) Project

Offline-first apps are no longer optional. When building Kotlin Multiplatform (KMP) applications, having a shared, type-safe, and performant database layer across Android, iOS, and JVM is critical.

With Room 2.7+ introducing official Multiplatform support, Android developers can finally reuse their familiar Room APIs beyond Android.

In this article, I’ll walk through how we integrated Room Database into a real KMP project (Digital Diary), covering architecture, configuration, DI, and platform setup, based entirely on this pull request:

👉 PR: https://github.com/piappstudio/digitaldiary/pull/1

Why Room for Kotlin Multiplatform?

Before Room KMP support, developers relied on:

  • SQLDelight
  • Custom SQLite wrappers
  • Platform-specific databases

Room now allows:

✅ Shared database schema
✅ Shared DAOs & entities
✅ Coroutine-based suspend APIs
✅ Familiar annotations (@Entity, @Dao, @Query)
✅ Single source of truth for data

⚠️ Current limitation: Room does not support Web/WASM targets yet.

Project Structure Overview

Key folders introduced in the PR:

composeApp/
 └── src/
     ├── commonMain/
     │   └── database/
     │       ├── entity/
     │       ├── dao/
     │       ├── DiaryRoomDatabase.kt
     │       └── AppDatabaseConstructor.kt
     ├── androidMain/
     └── iosMain/

All schema, DAO, and database definitions live in commonMain, while initialization is platform-specific.

Gradle Configuration (Room + KSP)

Plugins

[versions]
koin-bom = "4.1.1"
room = "2.8.4"
ksp = "2.3.5"
sqlite = "2.6.2"

[libraries]
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
# Optional SQLite Wrapper available in version 2.8.0 and higher
androidx-room-sqlite-wrapper = { module = "androidx.room:room-sqlite-wrapper", version.ref = "room" }

Changes in composeApp/build.gralde.kts

plugins {
  alias(libs.plugins.composeMultiplatform)
  alias(libs.plugins.ksp)
  alias(libs.plugins.room)
}

Dependencies

commonMain.dependencies {
    implementation(libs.room.runtime)
    implementation(libs.sqlite.bundled)
}

KSP Targets (Very Important)

dependencies {
    add("kspAndroid", libs.room.compiler)
    add("kspJvm", libs.room.compiler)
    add("kspIosX64", libs.room.compiler)
    add("kspIosArm64", libs.room.compiler)
    add("kspIosSimulatorArm64", libs.room.compiler)
}

Room Schema Directory

room {
    schemaDirectory("$projectDir/schemas")
}

This enables schema export for migrations and version tracking.


Defining Entities in commonMain

Entities define your database tables and are fully shared.

@Entity(tableName = "EventInfo")
data class EventInfo(
    @PrimaryKey val id: String,
    val title: String,
    val timestamp: Long?
)

Other entities added in the PR include:

  • ReminderInfo
  • MediaInfo
  • TagInfo
  • UserEvent
  • ReminderEvent

💡 Tip: Keep entities pure data classes without platform dependencies.

Creating DAOs (Suspend & Multiplatform-Safe)

Room KMP only supports suspend functions in DAOs.

@Dao
interface ReminderDao {

    @Query("SELECT * FROM ReminderInfo")
    suspend fun getAllReminders(): List<ReminderInfo>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(reminder: ReminderInfo)
}

❌ No LiveData
✅ Coroutines only
✅ Flows

Room Database Declaration

The database class lives in commonMain.

@Database(
    entities = [
        EventInfo::class,
        ReminderInfo::class,
        MediaInfo::class,
        TagInfo::class,
        UserEvent::class,
        ReminderEvent::class
    ],
    version = 1,
    exportSchema = false
)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class DiaryRoomDatabase : RoomDatabase() {

    abstract fun reminderDao(): ReminderDao
    abstract fun userEventDao(): UserEventDao
}

Key Points

  • @ConstructedBy is mandatory for KMP
  • No direct Room.databaseBuilder() in common code

Room Multiplatform Constructor (expect / actual)

Room requires an explicit constructor bridge.

commonMain

File: commonMain/database
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect object AppDatabaseConstructor :
    RoomDatabaseConstructor<DiaryRoomDatabase>

Room generates platform-specific actual implementations via KSP.

Platform-Specific Database Initialization

Define database.kt in Common

Define getDatabaseBuilder in common so that each platform will provide the better way to create a database

expect fun getDatabaseBuilder(): RoomDatabase.Builder<DiaryRoomDatabase>

✅ Android (composeApp.androidMain)

fun getDatabaseBuilder(context: Context): RoomDatabase.Builder<DiaryRoomDatabase> {
    val appContext = context.applicationContext
    val dbFile = appContext.getDatabasePath(DiaryRoomDatabase.DB_NAME)
    return Room.databaseBuilder<DiaryRoomDatabase>(
        context = appContext,
        name = dbFile.absolutePath
    ).addMigrations(DiaryRoomDatabase.MIGRATION_1_2, MIGRATION_2_3)
}

actual fun getDatabaseBuilder(): RoomDatabase.Builder<DiaryRoomDatabase> {
    val context: Context = getKoin().get()
    return getDatabaseBuilder(context)
}

🍎 iOS (composeApp.iosMain)

package com.piappstudio.digitaldiary.database

import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.driver.NativeSQLiteDriver
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask

actual fun getDatabaseBuilder(): RoomDatabase.Builder<DiaryRoomDatabase> {
    val dbFilePath = documentDirectory() + "/${DiaryRoomDatabase.DB_NAME}"
    return Room.databaseBuilder<DiaryRoomDatabase>(
        name = dbFilePath,
    ).setDriver(NativeSQLiteDriver())
}

@OptIn(ExperimentalForeignApi::class)
private fun documentDirectory(): String {
    val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
        directory = NSDocumentDirectory,
        inDomain = NSUserDomainMask,
        appropriateForURL = null,
        create = false,
        error = null,
    )
    return requireNotNull(documentDirectory?.path)
}

Jvm (composeApp.jvmMain)

package com.piappstudio.digitaldiary.database

import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import java.io.File

actual fun getDatabaseBuilder(): RoomDatabase.Builder<DiaryRoomDatabase> {
    val dbFile = File(System.getProperty("java.io.tmpdir"), DiaryRoomDatabase.DB_NAME)
    return Room.databaseBuilder<DiaryRoomDatabase>(
        name = dbFile.absolutePath,
    ).setDriver(BundledSQLiteDriver())
}

Using BundledSQLiteDriver() ensures consistent SQLite behavior across platforms.


9️⃣ Repository Layer

Repositories isolate database logic from UI:

class DiaryRepository(
    private val database: DiaryRoomDatabase
) {
    suspend fun getAllEvents() =
        database.userEventDao().getAllEvents()
}

Benefits:

  • Clean architecture
  • Easy testing
  • Platform-agnostic data access

Define the viewModel

Create a viewModel to handle the integration

package com.piappstudio.digitaldiary.ui.welcome

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import com.piappstudio.digitaldiary.database.DiaryRepository
import com.piappstudio.digitaldiary.database.entity.EventInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDateTime
import kotlin.time.Clock

class SplashViewModel (val diaryRepository: DiaryRepository) : ViewModel() {

    init {
        insertAndRead()
    }

    fun insertAndRead() {
        viewModelScope.launch (Dispatchers.IO) {

            val eventInfo = EventInfo().apply {
                title = "Test"
                description = "Test"
                emotion = "Test"
                dateInfo = Clock.System.now().toString()
            }
            val id = diaryRepository.insert(eventInfo)
            val userEvents = diaryRepository.getUserEvent(id)

            Logger.d("User Events: $userEvents.")

        }
    }
}


🔟 Dependency Injection with Koin

Shared Module

Create ViewModelModules.kt to define the DI

package com.piappstudio.digitaldiary.di

import com.piappstudio.digitaldiary.ui.welcome.SplashViewModel
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module

val viewModelModules = module {
    singleOf(::SplashViewModel)
}

DI for Database

package com.piappstudio.digitaldiary.di

import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import com.piappstudio.digitaldiary.database.DiaryRepository
import com.piappstudio.digitaldiary.database.DiaryRoomDatabase
import com.piappstudio.digitaldiary.database.ReminderRepository
import com.piappstudio.digitaldiary.database.dao.ReminderDao
import com.piappstudio.digitaldiary.database.dao.UserEventDao
import com.piappstudio.digitaldiary.database.getDatabaseBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import org.koin.dsl.module

val databaseModule = module {
    single<DiaryRoomDatabase> {
        getDatabaseBuilder()
            .setQueryCoroutineContext(Dispatchers.IO)
            .build()
    }
    single<UserEventDao> {
        get<DiaryRoomDatabase>().userEventDao()
    }
    single<ReminderDao> {
        get<DiaryRoomDatabase>().reminderDao()
    }

    single<DiaryRepository> {
        DiaryRepository(get())
    }
    single<ReminderRepository> {
        ReminderRepository(get())
    }
}

val commonModule = listOf(databaseModule, viewModelModules)

Android Module (composeApp/androidMain)

Create KoinInitalizer.kt

package com.piappstudio.digitaldiary.di

import android.content.Context
import com.piappstudio.digitaldiary.di.commonModule
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.GlobalContext
import org.koin.core.context.startKoin
import org.koin.core.logger.Level

/**
 * Initialize Koin with Android-specific configuration.
 * This should be called from the Application class onCreate() method.
 *
 * Usage: In your custom Application class:
 * ```
 * override fun onCreate() {
 *     super.onCreate()
 *     initKoinAndroid(this)
 * }
 * ```
 */
@Suppress("unused")
fun initKoinAndroid(context: Context) {
    if (GlobalContext.getOrNull() == null) {
        startKoin {
            androidLogger(Level.DEBUG)
            androidContext(context)
            modules(commonModule)
        }
    }
}

Create application class and call above function.

package com.piappstudio.digitaldiary

import android.app.Application
import com.piappstudio.digitaldiary.di.initKoinAndroid

/**
 * Custom Application class for DigitalDiary.
 * Handles initialization of Koin dependency injection with Android context.
 */
class DigitalDiaryApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // Initialize Koin with Android context
        initKoinAndroid(this)
    }
}

Android Module (androidApp)

DO NOT forget to update androidManifest.xml with this name in androidApp

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <application
        android:name="com.piappstudio.digitaldiary.DigitalDiaryApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@android:style/Theme.Material.Light.NoActionBar">
        <activity
            android:exported="true"
            android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

Update App.kt to bind koin modules

@Composable
@Preview
fun App() {

    if (KoinPlatformTools.defaultContext().getOrNull() == null) {
        startKoin(appDeclaration = {
            // On Android, context is provided through the Application class
            // On other platforms, we only load the common modules
            modules(commonModule)
        })
    }
    
}

Each platform supplies only what it knows — the rest is shared.


⚠️ Limitations & Notes

  • ❌ No Web/WASM support yet
  • ❌ No reactive Flow queries
  • ⚠️ Migrations still evolving in KMP
  • ✅ Ideal for Android + iOS + Desktop apps

🏁 Final Thoughts

This Room integration enables:

✔️ One database codebase
✔️ Familiar Android APIs
✔️ Clean KMP architecture
✔️ Easier long-term maintenance

If you’re an Android developer moving into Kotlin Multiplatform, Room KMP is now a first-class, production-ready option.

Leave a Comment

Your email address will not be published. Required fields are marked *