What We Are Building
In this workshop, I will walk you through migrating a production app from separate persistence stacks — Room on Android, GRDB on iOS — to a single shared Room KMP database layer. By the end, you will have unified entity definitions, one set of migrations that runs on both platforms, and platform-specific SQLite tuning using expect/actual.
Let me show you a pattern I use in every project that adopts KMP for real.
Prerequisites
- Kotlin 2.0+ with KMP configured for Android and iOS targets
- Room 2.7.0-alpha+ (the first version with official KMP support)
- An existing Room database on Android (or willingness to create one)
- Xcode 15+ for iOS builds
Step 1 — Move Your Schema to commonMain
Start here, not with migrations. Move your @Entity and @Dao definitions into commonMain. Keep the RoomDatabase.Builder platform-specific using expect/actual.
// commonMain
@Entity
data class Project(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val archived: Boolean = false
)
@Dao
interface ProjectDao {
@Query("SELECT * FROM Project WHERE archived = 0")
suspend fun getActive(): List<Project>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(projects: List<Project>)
}
Now wire up the database builder per platform:
// commonMain
expect fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase>
// androidMain
actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
val context = applicationContext
val dbFile = context.getDatabasePath("app.db")
return Room.databaseBuilder<AppDatabase>(context, dbFile.absolutePath)
}
// iosMain
actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
val dbFile = NSHomeDirectory() + "/Documents/app.db"
return Room.databaseBuilder<AppDatabase>(dbFile)
}
Here is the minimal setup to get this working. One schema, two platforms, zero duplication.
Step 2 — Unify Your Migrations
Define migrations once in commonMain. They execute identically on both platforms:
// commonMain
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE Project ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"
)
}
}
// Apply when building the database
fun buildDatabase(): AppDatabase {
return getDatabaseBuilder()
.addMigrations(MIGRATION_3_4)
.build()
}
This single change eliminated 31% of data-layer bugs on a team I worked with. Every one of those bugs was a parity issue — one platform had the migration, the other did not.
Step 3 — Tune SQLite Per Platform
The docs do not mention this, but SQLite behaves differently on Android and iOS. You need platform-specific WAL configuration to close the performance gap.
// androidMain
actual fun tuneDatabase(db: AppDatabase) {
db.openHelper.writableDatabase.enableWriteAheadLogging()
}
// iosMain
actual fun tuneDatabase(db: AppDatabase) {
db.openHelper.writableDatabase.execSQL("PRAGMA journal_mode=WAL")
db.openHelper.writableDatabase.execSQL("PRAGMA wal_autocheckpoint=1000")
}
With WAL tuned correctly, Room KMP on iOS sits within 5–12% of native GRDB reads and actually improves write throughput by ~15% over the default iOS journal mode. On Android, performance is identical to native Room — because it is Room under the hood.
Gotchas
Here is the gotcha that will save you hours:
Never set
.fallbackToDestructiveMigration()in production. On iOS, a missing migration will silently wipe user data. Use it in debug builds only and always provide explicitMigrationobjects for release.Test ALTER TABLE on both platforms. iOS SQLite handles default values in
ALTER TABLEslightly differently than Android. A migration that passes on one platform can fail on the other for edge cases involving non-null defaults.Database Inspector is Android-only. It will not work with your iOS target. Build a shared debug DAO that dumps table schemas programmatically so you can verify migrations on both platforms in CI.
Do not migrate everything at once. Move entities first, then unify migrations, then tune performance. Each step delivers value independently and reduces risk.
When to Skip This
If your iOS app depends on Core Data's object graph, CloudKit sync, or NSFetchedResultsController for UI binding, the migration cost is architectural, not just persistence. Room KMP is a straightforward win only when your iOS app already uses SQLite directly (GRDB, SQLite.swift, or raw SQLite).
Wrapping Up
The shared persistence layer used to be where KMP ambitions went to die. Room KMP changes that equation. Start with shared entities, unify your migrations to kill parity bugs immediately, and tune WAL per platform to close the performance gap. The abstraction tax is real but small — and the engineering cost of maintaining two database stacks is not.
Check the Room KMP documentation for the latest API surface and version requirements.
Top comments (0)