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:
ReminderInfoMediaInfoTagInfoUserEventReminderEvent
💡 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
@ConstructedByis 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
Flowqueries - ⚠️ 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.

