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 @Database and @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/ to shared/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, @Dao annotations unchanged
  • @Query, @Insert, @Update, @Delete work the same
  • suspend functions and Flow<T> return types supported
  • @Transaction for complex queries
  • Foreign keys, indices, and constraints work identically

Migration Support:

  • Existing Migration objects 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/actual

Code 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/database with @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 Context to 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 Context to 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/database dependencies (Android-only)
  • Migrate to kotlinx.serialization post-Week 2
  • Lower risk, faster migration

Option 2: Proper (Migrate now)

  • Add @Serializable to 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 Migration API
  • 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() // ❌ Crashes

Solution:

// 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)

Code Repository:

Obsidian Vault:


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)