| name | kmp-expert-room |
| description | Wire a Room 2.7+ database into a KMP project — module build config with per-target KSP, entities/DAOs, the expect/actual builder, and single-source-of-truth repository integration. Use whenever the user adds local persistence, an offline cache or offline-first behavior, mentions Room, entities/DAOs/@Database, KSP for Room, schema/migrations, or debugs the AGP 9 schema-export warning or a Room-vs-AGP-version issue. Applies the standard and connects it to a real consumer; does not just describe it. |
Skill: KMP Expert Room
This skill defines how to implement a local database using native Room for Kotlin Multiplatform, maintaining clear boundaries between database models and domain models.
Workflow
This skill applies the standard. Do the steps in order; each points to the section with the full code and its warnings. Do not stop at summarizing.
- Module build config (§1) — plugins,
room { schemaDirectory }, one KSP dependency per target the project builds. Under AGP 9 the Android schema-export warning is expected and benign — do not downgrade agp/kotlin to silence it.
- Entities, DAOs,
@Database (§2) — @Entity in the data layer, DAOs exposing Flow, @ConstructedBy + the expect object constructor. For a full-collection sync, use replaceAll in a @Transaction, never bare @Upsert.
- Platform builders (§3) — the
expect/actual builder factory; applicationContext on Android, NSDocumentDirectory on iOS.
- Repository integration (§4) — this is the step that is usually skipped, and skipping it leaves a database nothing reads from. Wire it as the source of truth: the UI observes the DB, the network only syncs into it.
- DI (§5) — register the builder in the platform module so the common repository resolves the DB cleanly.
- Verify by running. A green build and a passing graph test do not prove the cache works — offline behavior only shows at runtime. Drive the offline path (load online, kill, reopen in airplane mode) and confirm data still renders.
[!NOTE]
Decide up front whether this database is a cache or a system of record — it changes several later choices. A cache (a product catalog, reference tables) may use fallbackToDestructiveMigration and full replacement, and need not be cleared on logout. A system of record (data the user created and cannot re-fetch) needs real migrations and must never be destructively dropped. When in doubt, ask; do not default to destructive.
[!NOTE]
Placement (architecture-agnostic): entities, DAOs, and the database belong to the data layer; the platform database builders go in the platform source sets (androidMain / iosMain). In a single-module project they are packages under commonMain. This skill does not assume a module layout.
In a modular project (feature + layers), Room forces one exception: @Database must list every entity, so it can only live where it sees them all. Entities and DAOs therefore cannot be distributed into their feature modules — a :core:database holding @Database cannot depend on :features:* without inverting the dependency. Keep entities, DAOs, and the database together in the database module, and let features depend on it and map entities to their own domain models. The trade-off is real and worth naming: a :core module ends up knowing each feature's persisted shape. The alternative — one database per feature — costs a separate file, connection, and migration history per feature, and is rarely worth it. See kmp-modular-architecture.
Persistence Standards
- Entity Purity: Room
@Entity classes belong strictly to the data layer. They must be mapped to pure domain models before leaving the Repository.
- Reactive DAOs: Prefer exposing
Flow<List<Entity>> in DAOs to enable automatic UI updates on database changes.
- Multiplatform Construction: Use
@ConstructedBy and the expect/actual pattern to instantiate the database builder depending on the target platform (Android vs. iOS).
- Safe Migrations: Implement clear schema migration strategies to prevent data loss.
1. Module Build Configuration
Room needs the ksp and room plugins, a schema directory, and one KSP dependency per target the project builds. Versions come from the catalog (kmp-setup-base-dependencies).
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.ksp)
alias(libs.plugins.room)
}
room {
schemaDirectory("$projectDir/schemas")
}
kotlin {
androidLibrary { }
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
api(libs.room.runtime)
implementation(libs.sqlite.bundled)
}
}
}
dependencies {
add("kspAndroid", libs.room.compiler)
add("kspIosArm64", libs.room.compiler)
add("kspIosSimulatorArm64", libs.room.compiler)
}
[!WARNING]
This warning is expected under AGP 9 and must not be "fixed":
w: [ksp] AppDatabase.kt: Schema export directory was not provided to the annotation
processor so Room cannot export the schema...
The Room Gradle plugin does not wire room.schemaLocation into the Android KSP task created by AGP 9's KMP library plugin. It is benign: the native targets export the schema, it is identical across targets, and code generation works. Reading it as "Room does not support AGP 9" and downgrading agp/kotlin destroys a working project to silence a cosmetic warning.
Room and KSP work with AGP 9. The configurations exist — verify rather than assume:
tasks.register("printKspConfigs") {
val names = configurations.names.filter { it.startsWith("ksp") }.sorted()
doLast { names.forEach { println(it) } }
}
Do not migrate the module to the legacy android { } + androidTarget() pattern to obtain kspAndroid. It already exists.
2. Database Definition (Entities and DAOs)
Entities and DAOs Setup (commonMain)
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Entity(tableName = "items")
data class ItemEntity(
@PrimaryKey val id: String,
val name: String,
val timestamp: Long
)
@Dao
interface ItemDao {
@Query("SELECT * FROM items")
fun observeItems(): Flow<List<ItemEntity>>
@Transaction
suspend fun replaceAll(items: List<ItemEntity>) {
clearItems()
upsertItems(items)
}
@Upsert
suspend fun upsertItems(items: List<ItemEntity>)
@Query("DELETE FROM items")
suspend fun
}
[!WARNING]
@Upsert never deletes — syncing with it alone leaks records forever. Upsert inserts and updates, so a row deleted on the server stays in the cache for the rest of the app's life: the table only ever grows, and the UI keeps showing items the backend no longer has. This never fails, never logs, and looks correct in every test that only checks that data arrives.
For a full-collection sync, delete and re-insert inside a @Transaction (above). The instinct to avoid the delete — "it would blank the table mid-refresh" — is right about the risk and wrong about the fix: @Transaction is what removes the flash, because Room only emits after the commit.
@Upsert on its own is correct for incremental writes (a single edited row), not for replacing a collection. Note that full replacement is also wrong for a paginated cache, where clearing page 1 would drop every other page — that case needs Paging3 + RemoteMediator.
Main Database Class (commonMain)
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.ConstructedBy
import androidx.room.RoomDatabaseConstructor
@Database(
entities = [ItemEntity::class],
version = AppDatabase.DB_VERSION
)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
companion object {
const val DB_NAME = "app_db.db"
const val DB_VERSION = 1
}
abstract fun itemDao(): ItemDao
}
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
override fun initialize(): AppDatabase
}
3. Platform Setup (Database Builders)
Common Builder Configuration (commonMain)
import androidx.room.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
fun getDatabase(builder: RoomDatabase.Builder<AppDatabase>): AppDatabase {
return builder
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
}
Android Builder (androidMain)
import android.content.Context
import androidx.room.Room
import androidx.room.RoomDatabase
fun getDatabaseBuilder(ctx: Context): RoomDatabase.Builder<AppDatabase> {
val dbFile = ctx.getDatabasePath(AppDatabase.DB_NAME)
return Room.databaseBuilder<AppDatabase>(
context = ctx.applicationContext,
name = dbFile.absolutePath
)
}
iOS Builder (iosMain)
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
import androidx.room.Room
import androidx.room.RoomDatabase
fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
val dbFilePath = documentDirectory() + "/${AppDatabase.DB_NAME}"
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)
}
4. Repository Integration (Single Source of Truth)
A database nothing reads from is dead weight. Wire it the moment it exists — and wire it as the source of truth, not as a fallback.
The database is what the UI reads. The network only synchronizes it. The repository exposes the two concerns separately:
class ProductRepositoryImpl(
private val dataSource: ProductRemoteDataSource,
private val productDao: ProductDao
) : ProductRepository {
override fun observeProducts(): Flow<List<Product>> =
productDao.observeProducts().map { entities -> entities.map { it.toDomain() } }
override suspend fun refreshProducts(): ResultState<Unit> = try {
val products = dataSource.getProducts().toDomain()
productDao.replaceAll(products.map { it.toEntity() })
ResultState.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
e.toResultError("refresh products")
}
}
Offline support is then a property of the design, not a feature to add: the Flow emits whatever is stored, and a failed refresh is reported without touching the data.
[!WARNING]
Do not emit cache and network through one result stream. The tempting shape — read the cache once, emit it, then emit the network result — looks equivalent and is not:
emit(ResultState.Loading)
val cached = productDao.observeProducts().first()
if (cached.isNotEmpty()) emit(ResultState.Success(cached.toPage()))
try {
val page = dataSource.getProducts().toDomain()
productDao.upsertProducts(page.products.map { it.toEntity() })
emit(ResultState.Success(page))
} catch (e: Exception) {
if (cached.isEmpty()) emit(ResultState.Error(e, ...))
}
.first() throws away Room's reactivity: a later write updates nothing. Success is emitted twice for one load. And if (cached.isEmpty()) exists only because data and transport state share one channel — with the database as the source of truth that guard has nowhere to live, because a refresh error never had the power to blank the screen.
[!NOTE]
Not every read belongs in the database. Cache what is a durable, listable resource (a catalog, reference tables, history). Go straight to the network for data that must be fresh (a payment status, a balance, a session check) — staleness there is a correctness bug, not a convenience. Server-paginated lists are a third case: they need Paging3 + RemoteMediator, not a full-replacement cache.
[!NOTE]
Clear what is scoped to the user on logout — not the whole database. A public catalog is not the previous user's data; deleting it on logout only forces a re-download. Rows tied to an account (orders, drafts, messages) must go. Note that the skill producing the clearX() DAO method is not the one that owns the session lifecycle: wiring it is the job of whoever handles logout, and an unused clearX() is the signal that nobody did.
5. Best Practices