Room can now live in shared Kotlin Multiplatform code. In this guide, I will show how I add it to a KMP app: common entities and DAOs, platform database builders, KSP for Android and iOS, and Koin wiring so the rest of the app can inject the database without caring about the platform.
The final structure is small, but each part has a job. Room needs generated code, SQLite needs a driver, Android and iOS need different database file locations, and your shared module needs one stable API that both apps can use.
The setup has a few pieces that work together:
room-runtime and bundled SQLite in commonMain.room-compiler through KSP for Android and iOS targets.AppDatabase, DAO, and entity.AppDatabase and ExampleDao.composeApp/schemas.Start in gradle/libs.versions.toml. The template adds versions for Room, SQLite, and KSP, then exposes the libraries and plugins through aliases.
[versions]
room = "2.8.4"
sqlite = "2.6.2"
ksp = "2.3.7"
[libraries]
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-sqlite-wrapper = { module = "androidx.room:room-sqlite-wrapper", version.ref = "room" }
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
androidx-room = { id = "androidx.room", version.ref = "room" }This keeps all dependency versions in one place. That matters more in KMP than in a single Android app because the same dependency graph is feeding multiple targets. When Room, SQLite, Kotlin, and KSP move independently, the version catalog is where you want the mismatch to be obvious.
At the root, declare the KSP and Room plugins with apply false. That makes the aliases available without applying them to every module.
plugins {
alias(libs.plugins.ksp) apply false
alias(libs.plugins.androidx.room) apply false
}Then apply both plugins in composeApp/build.gradle.kts, where the database code actually lives.
plugins {
alias(libs.plugins.ksp)
alias(libs.plugins.androidx.room)
}Room uses generated code. KSP is the processor that creates the implementation behind your annotated database and DAO types. The Room Gradle plugin also gives you schema export configuration, which becomes useful as soon as you ship a database and need migrations later.
Add -Xexpect-actual-classes for Android and iOS compiler options.
androidTarget {
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
}
iosTarget.compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}The shared database declaration uses an expect object for the Room constructor. You do not manually write the platform actuals. The Room compiler generates them. This flag allows that expect and generated actual setup to compile cleanly.
commonMain gets Room runtime and bundled SQLite because the database, entity, and DAO live in shared code.
commonMain.dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.sqlite.bundled)
}androidMain gets the Room SQLite wrapper because Android also needs Room's Android-side integration.
androidMain.dependencies {
implementation(libs.androidx.room.sqlite.wrapper)
}Then add the compiler to every target that needs generated Room code.
dependencies {
add("kspAndroid", libs.androidx.room.compiler)
add("kspIosSimulatorArm64", libs.androidx.room.compiler)
add("kspIosArm64", libs.androidx.room.compiler)
}This is the part people often miss. If Android gets KSP but iOS does not, your Android build may pass while the shared framework still fails. KMP does not have one generated output for every target. Each target that compiles Room code needs the compiler configured.
The shared database belongs in commonMain because the app should use one database contract from both platforms.
@Database(entities = [ExampleEntity::class], version = 1)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun getExampleDao(): ExampleDao
}
@Suppress("KotlinNoActualForExpect")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
override fun initialize(): AppDatabase
}
fun getRoomDatabase(
builder: RoomDatabase.Builder<AppDatabase>
): AppDatabase {
return builder
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
}@Database tells Room which entities are part of the schema. @ConstructedBy points Room to the generated constructor. BundledSQLiteDriver gives both platforms the SQLite driver through the shared setup, and Dispatchers.IO keeps database work off the main thread.
The template uses a small example entity so the database can be compiled, injected, and queried immediately.
@Entity
data class ExampleEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val content: String
)@Dao
interface ExampleDao {
@Insert
suspend fun insert(item: ExampleEntity)
@Query("SELECT count(*) FROM ExampleEntity")
suspend fun count(): Int
@Query("SELECT * FROM ExampleEntity")
fun getAllAsFlow(): Flow<List<ExampleEntity>>
}The DAO deliberately includes a suspend insert, a suspend count, and a Flow query. That gives you the three shapes most apps need: write once, read once, and observe changes over time. It also proves that coroutines and Flow are working across the shared module.
The database contract is shared, but the file path is not. Android should use the app database directory. iOS should use the app document directory. That is why I keep the builders platform-specific.
fun getDatabaseBuilder(context: Context): RoomDatabase.Builder<AppDatabase> {
val appContext = context.applicationContext
val dbFile = appContext.getDatabasePath("my_app_room.db")
return Room.databaseBuilder<AppDatabase>(
context = appContext,
name = dbFile.absolutePath
)
}fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
val dbFilePath = documentDirectory() + "/my_app_room.db"
return Room.databaseBuilder<AppDatabase>(
name = dbFilePath,
)
}
@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)
}This split keeps platform APIs out of shared code. Shared code knows how to configure Room once it receives a builder. Platform code knows where the database file should live. That boundary is the main reason this setup stays readable.
Each platform module creates AppDatabase using its own builder.
actual val platformModule = module {
single<AppDatabase> {
val builder = getDatabaseBuilder(get())
getRoomDatabase(builder)
}
}actual val platformModule = module {
single<AppDatabase> {
val builder = getDatabaseBuilder()
getRoomDatabase(builder)
}
}Then shared Koin code exposes the DAO from the database.
val sharedModule = module {
single<ExampleDao> { get<AppDatabase>().getExampleDao() }
}This gives your feature code a clean dependency. A repository or view model should ask for ExampleDao, not build a database or know about Android context, iOS file managers, or Room drivers.
Add a Room schema directory.
room {
schemaDirectory("$projectDir/schemas")
}That produces a JSON schema file like composeApp/schemas/org.giusniyyel.kmm.data.local.AppDatabase/1.json. You want that checked in. It gives Room a stable record of version 1, which becomes the baseline for future migrations. Without schema history, migration testing gets harder and mistakes become easier to miss.
The important part is not the example table. You will replace ExampleEntity with your own local models. The pattern worth keeping is the separation:
If you are starting a Kotlin Multiplatform app, you do not need to assemble this from an empty folder. I keep this setup in KMM Template, with Room, Koin, Compose Multiplatform, Ktor, and the shared project structure already in place.
That is the real value of a template: not skipping learning, but removing setup noise so every new project starts with the same working baseline. Add your entities, replace the example DAO, keep the schema export, and build the actual product instead of debugging Gradle for the third time.