Adding Room to a Kotlin Multiplatform App | Daniel Campos
AR
ArticleAdding Room to a Kotlin Multiplatform App
ArticleDevelopment
Adding Room to a Kotlin Multiplatform App
DC
Daniel Campos
Share:
June 23, 20267 min read
DC
Daniel Campos
June 23, 20267 min read
Share:
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.
What this setup includes
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.
A shared AppDatabase, DAO, and entity.
Android and iOS database builders that choose the correct local file path.
Koin bindings for AppDatabase and ExampleDao.
A checked-in Room schema file under composeApp/schemas.
Step 1. Add Room, SQLite, and KSP versions
Start in gradle/libs.versions.toml. The template adds versions for Room, SQLite, and KSP, then exposes the libraries and plugins through aliases.
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.
Step 2. Apply the plugins at the root and app module
At the root, declare the KSP and Room plugins with apply false. That makes the aliases available without applying them to every module.
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.
Step 3. Enable expect and actual classes
Add -Xexpect-actual-classes for Android and iOS compiler options.
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.
Step 4. Wire dependencies into the right source sets
commonMain gets Room runtime and bundled SQLite because the database, entity, and DAO live in shared code.
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.
Step 5. Create the shared database
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.
Step 6. Add an entity and DAO
The template uses a small example entity so the database can be compiled, injected, and queried immediately.
ExampleEntity.ktkotlin
@Entity
data class ExampleEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val content: String
)
ExampleDao.ktkotlin
@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.
Step 7. Create platform database builders
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.
Database.android.ktkotlin
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
)
}
Database.ios.ktkotlin
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.
Step 8. Register the database with Koin
Each platform module creates AppDatabase using its own builder.
PlatformModule.android.ktkotlin
actual val platformModule = module {
single<AppDatabase> {
val builder = getDatabaseBuilder(get())
getRoomDatabase(builder)
}
}
PlatformModule.ios.ktkotlin
actual val platformModule = module {
single<AppDatabase> {
val builder = getDatabaseBuilder()
getRoomDatabase(builder)
}
}
Then shared Koin code exposes the DAO from the database.
KoinModule.ktkotlin
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.
Step 9. Export the Room schema
Add a Room schema directory.
composeApp/build.gradle.ktskotlin
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 pattern to keep
The important part is not the example table. You will replace ExampleEntity with your own local models. The pattern worth keeping is the separation:
Common code owns the database contract. Entities, DAOs, and the Room database live where Android and iOS can both use them.
Platform code owns the file path. Android and iOS choose their own database location without leaking platform APIs into shared code.
DI owns construction. Feature code asks for a DAO or repository, not a database builder.
Schemas are versioned from day one. Version 1 is not disposable. It is the first migration boundary.
Start from the template
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.