Room KMP Architecture
Created: 2025-10-21 | Agent: Agent 2 (AAM) Tags: room-kmp database kmp architecture sqlite
Overview
Room KMP (Kotlin Multiplatform) 2.8.1 enables sharing database code across Android, iOS, and other platforms while maintaining the familiar Room API. This document provides a conceptual overview of the Room KMP architecture, migration philosophy, and architectural patterns for the Archery Apprentice KMP database layer.
What is Room KMP?
Core Concept
Room KMP is Google’s official Kotlin Multiplatform extension of the Android Room library, allowing developers to write database code once in commonMain and use it across all platforms.
Key Features:
- Same API as Android Room - Minimal learning curve
- Cross-platform database - SQLite on Android, iOS, Desktop, Web
- KSP-powered code generation - Type-safe queries across all platforms
- Migration support - Existing Room migrations work unchanged
- expect/actual for instantiation - Platform-specific database creation
Supported Platforms
- ✅ Android - Native Room support (compiled to SQLite)
- ✅ iOS - SQLite via
BundledSQLiteDriver - ✅ JVM/Desktop - SQLite via JDBC
- ✅ Web/JS - In-memory SQLite (experimental)
- ✅ Native - Platform-specific SQLite drivers
Architecture Overview
Module Structure
shared/
└── database/
└── src/
├── commonMain/kotlin/
│ ├── database/
│ │ ├── ArcheryDatabase.kt # Database class
│ │ └── DatabaseConstructor.kt # expect declaration
│ ├── dao/
│ │ ├── RoundDao.kt # DAO interfaces
│ │ └── TournamentDao.kt
│ ├── converters/
│ │ └── Converters.kt # TypeConverters
│ └── migrations/
│ └── Migration_17_18.kt # Migration files
├── androidMain/kotlin/
│ └── database/
│ └── DatabaseConstructor.kt # actual (Android)
└── iosMain/kotlin/
└── database/
└── DatabaseConstructor.kt # actual (iOS)
Key Components
1. Database Class (commonMain)
- Defines entities, version, and DAOs
- Annotated with
@Databaseand@ConstructedBy - Platform-agnostic
2. DAOs (commonMain)
- Define queries, inserts, updates, deletes
- 100% shared across platforms
- Type-safe SQL queries
3. Entities (shared/domain)
- Data classes with Room annotations
- Migrated from
app/toshared/domain - No platform-specific code
4. TypeConverters (commonMain)
- Convert complex types to primitives
- Used for enums, lists, custom types
- Platform-agnostic (with proper serialization)
5. Database Constructor (expect/actual)
- expect in
commonMain - actual in
androidMain(Context-based) - actual in
iosMain(file path-based)
6. Migrations (commonMain)
- SQL migrations (platform-agnostic)
- Reuse existing Android migrations
- Applied on all platforms
Room KMP vs Android Room
Similarities ✅
API Compatibility:
@Database,@Entity,@Daoannotations unchanged@Query,@Insert,@Update,@Deletework the samesuspendfunctions andFlow<T>return types supported@Transactionfor complex queries- Foreign keys, indices, and constraints work identically
Migration Support:
- Existing
Migrationobjects work unchanged - SQL migrations are platform-agnostic
addMigrations()applies to all platforms
Type Safety:
- Compile-time query validation on all platforms
- Type-safe DAOs generated by KSP
Differences ⚠️
Database Instantiation:
// Android Room (before)
Room.databaseBuilder(context, ArcheryDatabase::class.java, "archery.db")
.addMigrations(...)
.build()
// Room KMP (after) - Uses expect/actual
@ConstructedBy(ArcheryDatabaseConstructor::class)
abstract class ArcheryDatabase : RoomDatabase() { ... }
// Platform-specific instantiation via expect/actualCode Generation:
// Android Room: KAPT
plugins {
id("kotlin-kapt")
}
dependencies {
kapt("androidx.room:room-compiler:2.6.1")
}
// Room KMP: KSP (Kotlin Symbol Processing)
plugins {
alias(libs.plugins.ksp)
}
dependencies {
add("kspCommonMainMetadata", "androidx.room:room-compiler:2.8.1")
add("kspAndroid", "androidx.room:room-compiler:2.8.1")
add("kspIosX64", "androidx.room:room-compiler:2.8.1")
// ... other iOS targets
}SQLite Driver:
// Android: Built-in SQLite driver
// Room KMP: Explicit driver configuration
// Android
// No explicit driver needed
// iOS
Room.databaseBuilder<ArcheryDatabase>(...)
.setDriver(BundledSQLiteDriver()) // ✅ Explicit driver
.build()Migration Philosophy
Incremental Migration Strategy
Phase 1: Entities (Agent 1)
- Move entity classes to
shared/domain - Keep Room annotations unchanged
- Remove platform-specific code (java.util.Date, System.currentTimeMillis)
Phase 2: DAOs (Agent 2)
- Move DAO interfaces to
shared/database - Update entity imports (point to shared/domain)
- No code changes (DAOs are interfaces)
Phase 3: Database Class (Agent 2)
- Create database in
shared/databasewith@ConstructedBy - Configure expect/actual for instantiation
- Move migrations to
shared/database
Phase 4: TypeConverters (Agent 2)
- Move converters to
shared/database - Migrate Gson → kotlinx.serialization (KMP-compatible)
Phase 5: Testing (Agent 2)
- Common tests for DAOs
- Android tests for platform-specific code
- iOS tests (future)
Backward Compatibility
Goal: Existing app continues to work during migration
Strategy:
- Keep app module database as fallback
- Gradually switch to shared database
- All migrations preserved and working
- No data loss, no schema changes
expect/actual Pattern for Database Instantiation
Why expect/actual?
Problem:
- Android needs
Contextto get database path - iOS uses file system paths (
NSHomeDirectory()) - Web uses in-memory database
- Each platform has different initialization requirements
Solution:
- expect declaration in
commonMain(interface) - actual implementations in
androidMain,iosMain(platform-specific)
expect Declaration (commonMain)
// shared/database/src/commonMain/kotlin/database/DatabaseConstructor.kt
package com.archeryapprentice.database
import androidx.room.RoomDatabaseConstructor
expect object ArcheryDatabaseConstructor : RoomDatabaseConstructor<ArcheryDatabase> {
override fun initialize(): ArcheryDatabase
}actual Implementation (Android)
// shared/database/src/androidMain/kotlin/database/DatabaseConstructor.kt
package com.archeryapprentice.database
import android.content.Context
import androidx.room.Room
import androidx.room.RoomDatabaseConstructor
import com.archeryapprentice.database.migrations.getAllMigrations
actual object ArcheryDatabaseConstructor : RoomDatabaseConstructor<ArcheryDatabase> {
private lateinit var applicationContext: Context
// Called from Application.onCreate()
fun initialize(context: Context) {
applicationContext = context.applicationContext
}
override fun initialize(): ArcheryDatabase {
val dbFile = applicationContext.getDatabasePath("archery.db")
return Room.databaseBuilder<ArcheryDatabase>(
context = applicationContext,
name = dbFile.absolutePath
)
.addMigrations(*getAllMigrations()) // All 19 existing migrations
.build()
}
}Initialization in Android App:
// app/src/main/java/ArcheryApplication.kt
class ArcheryApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize database constructor with Context
ArcheryDatabaseConstructor.initialize(this)
}
}actual Implementation (iOS - Future)
// shared/database/src/iosMain/kotlin/database/DatabaseConstructor.kt
package com.archeryapprentice.database
import androidx.room.Room
import androidx.room.RoomDatabaseConstructor
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import com.archeryapprentice.database.migrations.getAllMigrations
import platform.Foundation.NSHomeDirectory
actual object ArcheryDatabaseConstructor : RoomDatabaseConstructor<ArcheryDatabase> {
override fun initialize(): ArcheryDatabase {
val dbFile = NSHomeDirectory() + "/archery.db"
return Room.databaseBuilder<ArcheryDatabase>(
name = dbFile,
factory = { ArcheryDatabase::class.instantiateImpl() }
)
.setDriver(BundledSQLiteDriver())
.addMigrations(*getAllMigrations())
.build()
}
}Key Differences:
- Android: Uses
Contextto get database path - iOS: Uses
NSHomeDirectory()for file system path - iOS: Explicitly sets
BundledSQLiteDriver - Both: Apply same migrations (SQL is platform-agnostic)
TypeConverter Migration: Gson → kotlinx.serialization
Why Migrate?
Problem: Gson is Android/JVM-specific, not available on iOS
Solution: kotlinx.serialization is KMP-native (works on all platforms)
Current TypeConverters (Gson-based)
// app/src/main/java/.../db/TypeConverters.kt
import com.google.gson.Gson // ❌ Android/JVM only
class Converters {
private val gson = Gson()
@TypeConverter
fun fromParticipantsList(participants: List<SessionParticipant>?): String {
return gson.toJson(participants)
}
@TypeConverter
fun toParticipantsList(json: String?): List<SessionParticipant>? {
return gson.fromJson(json, object : TypeToken<List<SessionParticipant>>() {}.type)
}
}Target TypeConverters (kotlinx.serialization)
// shared/database/src/commonMain/kotlin/converters/Converters.kt
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json // ✅ KMP-compatible
class Converters {
@TypeConverter
fun fromParticipantsList(participants: List<SessionParticipant>?): String {
return Json.encodeToString(participants)
}
@TypeConverter
fun toParticipantsList(json: String?): List<SessionParticipant>? {
return json?.let { Json.decodeFromString(it) }
}
}Models Need @Serializable:
import kotlinx.serialization.Serializable
@Serializable
sealed class SessionParticipant {
@Serializable
data class LocalUser(val id: String, val name: String) : SessionParticipant()
@Serializable
data class NetworkUser(val id: String, val name: String) : SessionParticipant()
@Serializable
data class Guest(val id: String, val name: String) : SessionParticipant()
}Migration Strategy
Option 1: Quick (Keep Gson temporarily)
- Add Gson to
shared/databasedependencies (Android-only) - Migrate to kotlinx.serialization post-Week 2
- Lower risk, faster migration
Option 2: Proper (Migrate now)
- Add
@Serializableto all models - Replace Gson TypeConverters with kotlinx.serialization
- Higher upfront work, cleaner long-term
Recommendation: Option 1 for Week 2 (speed), Option 2 post-Week 2 (quality)
Database Migrations (Platform-Agnostic)
Current Migrations
Status: 19 migrations (v17 → v35)
Location: app/src/main/java/.../db/migrations/
Format: Room Migration objects with SQL
Example Migration:
// app/src/main/java/.../migrations/Migration_17_18.kt
val MIGRATION_17_18 = object : Migration(17, 18) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE rounds ADD COLUMN bowSetupId INTEGER NOT NULL DEFAULT 0")
}
}Migration to Room KMP
Target Location: shared/database/src/commonMain/kotlin/migrations/
Pattern:
// shared/database/src/commonMain/kotlin/migrations/Migration_17_18.kt
package com.archeryapprentice.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
val MIGRATION_17_18 = object : Migration(17, 18) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE rounds ADD COLUMN bowSetupId INTEGER NOT NULL DEFAULT 0")
}
}No Changes Needed:
- SQL is platform-agnostic
- Room KMP uses same
MigrationAPI - Migrations work on Android, iOS, all platforms
Migration Helper Function
// shared/database/src/commonMain/kotlin/migrations/AllMigrations.kt
fun getAllMigrations(): Array<Migration> {
return arrayOf(
MIGRATION_17_18,
MIGRATION_18_19,
MIGRATION_19_20,
// ... all 19 migrations
MIGRATION_34_35
)
}Used in Database Constructor:
// Android
Room.databaseBuilder<ArcheryDatabase>(...)
.addMigrations(*getAllMigrations()) // ✅ All 19 migrations
.build()
// iOS
Room.databaseBuilder<ArcheryDatabase>(...)
.addMigrations(*getAllMigrations()) // ✅ Same migrations
.build()Testing Migrations
Android Migration Test:
@RunWith(AndroidJUnit4::class)
class DatabaseMigrationTest {
@Test
fun migrateAll_17_to_35() {
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
ArcheryDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
// Create database at version 17
helper.createDatabase(TEST_DB_NAME, 17)
// Run all migrations
helper.runMigrationsAndValidate(
TEST_DB_NAME,
35,
true,
*getAllMigrations()
)
}
}iOS Migration Test (Future):
- Fresh installs start at v35 (no migrations needed)
- Future migrations (v36+) tested on iOS simulator
- Same SQL, different platform
KSP vs KAPT
Why KSP?
KAPT (Kotlin Annotation Processing Tool):
- ❌ JVM-only (doesn’t support KMP)
- ❌ Slow (generates Java stubs first)
- ❌ Large binary size
KSP (Kotlin Symbol Processing):
- ✅ KMP-native (works on all platforms)
- ✅ Fast (2x faster than KAPT)
- ✅ Smaller binary size
- ✅ Better error messages
Configuration Changes
Before (Android Room + KAPT):
// app/build.gradle.kts
plugins {
id("kotlin-kapt")
}
dependencies {
implementation("androidx.room:room-runtime:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
}After (Room KMP + KSP):
// shared/database/build.gradle.kts
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.ksp)
}
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
implementation(libs.room.runtime)
implementation(libs.sqlite.bundled)
}
}
}
dependencies {
// KSP for ALL targets
add("kspCommonMainMetadata", libs.room.compiler)
add("kspAndroid", libs.room.compiler)
add("kspIosX64", libs.room.compiler)
add("kspIosArm64", libs.room.compiler)
add("kspIosSimulatorArm64", libs.room.compiler)
}
// Configure generated source directory
kotlin.sourceSets.commonMain {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}KSP Benefits for Room KMP
Code Generation:
- DAOs generated in
commonMain(works on all platforms) - Type-safe queries across platforms
- Compile-time validation
Performance:
- Faster builds (KSP is 2x faster than KAPT)
- Parallel processing across targets
- Incremental compilation
Entity Migration Patterns
Platform-Specific Code Removal
Problem: Android-only APIs in entities
Solution: Platform abstractions
Example 1: java.util.Date → Long
// Before (Android-only)
import java.util.Date // ❌ JVM-only
data class ArrowScore(
val id: Long = 0,
val scoreValue: Int,
val enteredAt: Date = Date(), // ❌ Android-specific
)
// After (KMP-compatible)
data class ArrowScore(
val id: Long = 0,
val scoreValue: Int,
val enteredAt: Long = getCurrentTimeMillis(), // ✅ Platform-agnostic
)Example 2: System.currentTimeMillis() → Platform Abstraction
// Before (Android-only)
data class Round(
val id: Int = 0,
val roundName: String,
val createdAt: Long = System.currentTimeMillis(), // ❌ Android-specific
)
// After (KMP-compatible)
import com.archeryapprentice.platform.getCurrentTimeMillis
data class Round(
val id: Int = 0,
val roundName: String,
val createdAt: Long = getCurrentTimeMillis(), // ✅ Platform-agnostic
)Platform Abstraction (expect/actual):
// shared/common/src/commonMain/kotlin/platform/Time.kt
expect fun getCurrentTimeMillis(): Long
// shared/common/src/androidMain/kotlin/platform/Time.kt
actual fun getCurrentTimeMillis(): Long = System.currentTimeMillis()
// shared/common/src/iosMain/kotlin/platform/Time.kt
import platform.Foundation.NSDate
actual fun getCurrentTimeMillis(): Long =
(NSDate().timeIntervalSince1970 * 1000).toLong()Room Annotations (100% Compatible)
All Room annotations work in KMP:
@Entity,@PrimaryKey,@ColumnInfo@ForeignKey,@Index@TypeConverters,@Embedded@Relation(for complex queries)
Example KMP Entity:
// shared/domain/src/commonMain/kotlin/models/Round.kt
package com.archeryapprentice.domain.models
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.archeryapprentice.domain.models.equipment.BowSetup
@Entity(
tableName = "rounds",
foreignKeys = [
ForeignKey(
entity = BowSetup::class,
parentColumns = ["id"],
childColumns = ["bowSetupId"],
onDelete = ForeignKey.RESTRICT
)
],
indices = [
Index(value = ["bowSetupId"]),
Index(value = ["createdAt"]),
Index(value = ["tournamentId"]),
]
)
data class Round(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val roundName: String,
val numEnds: Int,
val numArrows: Int,
@ColumnInfo(name = "distance")
val distance: Distance,
@ColumnInfo(name = "targetSize")
val targetSize: TargetSize,
val createdAt: Long = getCurrentTimeMillis(),
// ... other fields
)Works on:
- ✅ Android
- ✅ iOS
- ✅ JVM/Desktop
- ✅ Web (future)
Testing Strategy
1. Common Tests (Unit Tests)
Location: shared/database/src/commonTest/kotlin/
Purpose: Test DAOs, entities, TypeConverters
Pattern:
// shared/database/src/commonTest/kotlin/dao/RoundDaoTest.kt
class RoundDaoTest {
private lateinit var database: ArcheryDatabase
private lateinit var roundDao: RoundDao
@BeforeTest
fun setup() {
database = Room.inMemoryDatabaseBuilder<ArcheryDatabase>()
.build()
roundDao = database.roundDao()
}
@Test
fun insertRound_returnsId() = runTest {
val round = Round(roundName = "Test", numEnds = 6, numArrows = 6)
val id = roundDao.insertRound(round)
assertNotNull(id)
assert(id > 0)
}
@Test
fun getRoundById_returnsRound() = runTest {
val round = Round(roundName = "Test", numEnds = 6, numArrows = 6)
val id = roundDao.insertRound(round)
val retrieved = roundDao.getRoundById(id.toInt())
assertNotNull(retrieved)
assertEquals("Test", retrieved?.roundName)
}
@AfterTest
fun teardown() {
database.close()
}
}Benefits:
- Run on JVM (fast)
- Platform-agnostic tests
- Shared across all platforms
2. Android Tests (Integration Tests)
Location: app/src/androidTest/kotlin/
Purpose: Test database initialization, migrations, Context-dependent code
Pattern:
@RunWith(AndroidJUnit4::class)
class ArcheryDatabaseTest {
private lateinit var database: ArcheryDatabase
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, ArcheryDatabase::class.java)
.build()
}
@Test
fun databaseCreated_hasAllDAOs() {
assertNotNull(database.roundDao())
assertNotNull(database.tournamentDao())
assertNotNull(database.bowSetupDao())
// ... all 14 DAOs
}
@After
fun teardown() {
database.close()
}
}3. iOS Tests (Future)
Location: shared/database/src/iosTest/kotlin/ (future)
Purpose: Test iOS-specific database initialization
Pattern: Similar to Android tests, but uses iOS test framework
Performance Considerations
Database Indexes
Strategy: Index all foreign keys and frequently queried columns
Example:
@Entity(
tableName = "rounds",
indices = [
Index(value = ["bowSetupId"]), // Foreign key
Index(value = ["createdAt"]), // Sorting
Index(value = ["tournamentId"]), // Foreign key
Index(value = ["syncStatus"]) // Filtering
]
)Impact:
- Faster queries (avoids full table scans)
- Efficient sorting and filtering
- Composite indices for multi-column queries
Query Optimization
Use @Transaction for complex queries:
@Dao
interface RoundDao {
@Transaction
@Query("""
SELECT rounds.*,
COUNT(end_scores.id) as completedEnds
FROM rounds
LEFT JOIN end_scores ON rounds.id = end_scores.roundId
WHERE rounds.id = :roundId
GROUP BY rounds.id
""")
suspend fun getRoundWithStats(roundId: Int): RoundWithDetails?
}Benefits:
- Single query (avoids N+1 problem)
- Atomic operation
- Better performance
Caching Strategy
Use in-memory caching for frequently accessed data:
class RoundRepositoryImpl(
private val roundDao: RoundDao
) : RoundRepository {
private val roundCache = LruCache<Int, Round>(maxSize = 50)
override suspend fun getRoundById(id: Int): Round? {
// Check cache first
roundCache.get(id)?.let { return it }
// Fetch from database
val round = roundDao.getRoundById(id)
// Cache result
round?.let { roundCache.put(id, it) }
return round
}
}Common Pitfalls & Solutions
Pitfall 1: Forgetting Platform-Specific Drivers (iOS)
Problem:
// iOS database initialization WITHOUT driver
Room.databaseBuilder<ArcheryDatabase>(name = dbFile).build() // ❌ CrashesSolution:
// iOS database initialization WITH driver
Room.databaseBuilder<ArcheryDatabase>(name = dbFile)
.setDriver(BundledSQLiteDriver()) // ✅ Required for iOS
.build()Pitfall 2: Using Android-Specific APIs in Entities
Problem:
data class Round(
val createdAt: Long = System.currentTimeMillis() // ❌ Android-only
)Solution:
import com.archeryapprentice.platform.getCurrentTimeMillis
data class Round(
val createdAt: Long = getCurrentTimeMillis() // ✅ Platform-agnostic
)Pitfall 3: Not Configuring KSP for All Targets
Problem:
dependencies {
add("kspAndroid", libs.room.compiler) // ❌ Only Android
}Solution:
dependencies {
add("kspCommonMainMetadata", libs.room.compiler)
add("kspAndroid", libs.room.compiler)
add("kspIosX64", libs.room.compiler)
add("kspIosArm64", libs.room.compiler)
add("kspIosSimulatorArm64", libs.room.compiler) // ✅ All targets
}Pitfall 4: Forgetting to Add Generated Sources
Problem:
Unresolved reference: RoundDao_Impl
Solution:
// shared/database/build.gradle.kts
kotlin.sourceSets.commonMain {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") // ✅ Add generated sources
}Architectural Benefits
1. Code Reuse Across Platforms
- Single codebase for database logic
- Shared DAOs (no duplication)
- Shared migrations (SQL is platform-agnostic)
2. Type Safety Everywhere
- Compile-time query validation on all platforms
- Generated DAO implementations (type-safe)
- No runtime query errors
3. Consistent Data Layer
- Same API on Android, iOS, Desktop, Web
- Same behavior across platforms
- Easier testing (common tests)
4. Future-Proof
- Official Google/JetBrains support
- Active development (Room KMP is evolving)
- Growing community (KMP adoption increasing)
Related Documentation
Code Repository:
- Room KMP Migration Guide (Implementation details)
- Module Architecture
- DI Strategy
Obsidian Vault:
- KMP Data Layer Architecture (Complete data layer overview)
- Repository Migration Strategy (Repository migration patterns)
- KMP Migration Progress (Project status tracking)
Last Updated: 2025-10-21 Status: Conceptual overview complete, implementation planned for Week 2 Phase 4 Next Steps: Entity migration (Agent 1) → DAO migration (Agent 2) → Database setup (Agent 2)