Lesson 28 of 83 advanced

Room, SQLite, Migrations, Transactions & Relations

Production Room setup: CRUD, TypeConverters, migrations, relationships, reactive queries, and testing

Open interactive version (quiz + challenge)

Real-world analogy

Room is like a very organised filing cabinet where SQLite is the actual cabinet. Room gives you labeled folders (@Entity), an index card system (@Dao with typed queries), and a filing clerk (@Database) who makes sure everything is stored correctly. Migrations are like reorganising the cabinet without losing any files — you tell the clerk exactly how to move things around.

What is it?

Room is Android's official SQLite ORM — it provides compile-time SQL verification, type-safe queries, and reactive data streams via Flow. @Entity maps Kotlin classes to tables, @Dao defines typed queries, @Database assembles the schema. TypeConverters handle complex types, Migrations preserve user data across schema changes, and @Relation/@Embedded model complex relationships without manual joins in application code.

Real-world relevance

Hazira Khata (school attendance/management app) stores student attendance records locally in Room for 500+ schools with intermittent connectivity. Migrations are critical — when a school added a 'late arrival' status, MIGRATION_1_2 added the column with a DEFAULT of 'present' to preserve historical records. The attendance DAO returns Flow> scoped to today's date — the UI updates instantly when the teacher marks a student present, even before the sync completes.

Key points

Code example

// 1. Entity definition
@Entity(
    tableName = "orders",
    indices = [
        Index(value = ["status"]),
        Index(value = ["assigned_to_id", "created_at"])
    ]
)
data class OrderEntity(
    @PrimaryKey val id: String,
    @ColumnInfo(name = "title") val title: String,
    @ColumnInfo(name = "status") val status: String, // stored as string, not int
    @ColumnInfo(name = "assigned_to_id") val assignedToId: String?,
    @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
    @ColumnInfo(name = "priority") val priority: Int = 0,
    @ColumnInfo(name = "tags") val tags: List<String> = emptyList() // TypeConverter needed
)

// 2. TypeConverter for List<String>
class Converters {
    @TypeConverter
    fun fromStringList(value: List<String>): String =
        Json.encodeToString(ListSerializer(String.serializer()), value)

    @TypeConverter
    fun toStringList(value: String): List<String> =
        Json.decodeFromString(ListSerializer(String.serializer()), value)
}

// 3. DAO interface
@Dao
interface OrderDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsertAll(orders: List<OrderEntity>)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsert(order: OrderEntity)

    @Update
    suspend fun update(order: OrderEntity)

    @Delete
    suspend fun delete(order: OrderEntity)

    @Query("SELECT * FROM orders ORDER BY created_at DESC")
    fun observeAll(): Flow<List<OrderEntity>>

    @Query("SELECT * FROM orders WHERE id = :id")
    suspend fun getById(id: String): OrderEntity?

    @Query("SELECT * FROM orders WHERE status = :status AND assigned_to_id = :employeeId")
    fun observeByStatusAndEmployee(status: String, employeeId: String): Flow<List<OrderEntity>>

    @Transaction
    @Query("SELECT * FROM orders WHERE id = :orderId")
    suspend fun getOrderWithItems(orderId: String): OrderWithItems?

    @Query("DELETE FROM orders WHERE status = 'completed' AND created_at < :cutoffTime")
    suspend fun deleteOldCompleted(cutoffTime: Long): Int
}

// 4. Relations
@Entity(tableName = "order_items")
data class OrderItemEntity(
    @PrimaryKey val itemId: String,
    @ColumnInfo(name = "order_id") val orderId: String,
    val name: String,
    val quantity: Int
)

data class OrderWithItems(
    @Embedded val order: OrderEntity,
    @Relation(
        parentColumn = "id",
        entityColumn = "order_id"
    )
    val items: List<OrderItemEntity>
)

// 5. Database class
@Database(
    entities = [OrderEntity::class, OrderItemEntity::class],
    version = 3,
    exportSchema = true
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun orderDao(): OrderDao
}

// 6. Migration
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL(
            "ALTER TABLE orders ADD COLUMN priority INTEGER NOT NULL DEFAULT 0"
        )
        db.execSQL(
            "CREATE INDEX IF NOT EXISTS index_orders_status ON orders(status)"
        )
    }
}

// 7. Database builder (in Hilt module)
@Provides @Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
    Room.databaseBuilder(context, AppDatabase::class.java, "fieldops.db")
        .addMigrations(MIGRATION_2_3)
        .fallbackToDestructiveMigrationOnDowngrade()
        .build()

// 8. Atomic multi-table write (manual transaction)
@Dao
interface OrderTransactionDao {
    @Transaction
    suspend fun insertOrderWithItems(order: OrderEntity, items: List<OrderItemEntity>) {
        upsert(order)
        upsertItems(items)
        // If either fails, SQLite rolls back both — atomicity guaranteed
    }

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsert(order: OrderEntity)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsertItems(items: List<OrderItemEntity>)
}

// 9. Testing Room in-memory
@RunWith(AndroidJUnit4::class)
class OrderDaoTest {
    private lateinit var db: AppDatabase
    private lateinit var dao: OrderDao

    @Before
    fun setup() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java
        ).build()
        dao = db.orderDao()
    }

    @After fun teardown() = db.close()

    @Test
    fun upsertAndObserve() = runTest {
        val order = OrderEntity("1", "Fix pump", "open", null)
        dao.upsert(order)
        val result = dao.observeAll().first()
        assertThat(result).hasSize(1)
        assertThat(result[0].title).isEqualTo("Fix pump")
    }
}

Line-by-line walkthrough

  1. 1. OrderEntity's tags field is List — SQLite has no list type so the Converters TypeConverter serialises it to JSON string on write and deserialises on read.
  2. 2. @Index on ['assigned_to_id', 'created_at'] creates a composite index — queries filtering by both columns use the index instead of a full table scan.
  3. 3. observeAll() returns Flow> — the absence of 'suspend' is intentional; Flow is lazy and starts collecting when subscribed.
  4. 4. observeByStatusAndEmployee takes primitive parameters — Room validates these at compile time, catching typos in parameter names before the app ships.
  5. 5. @Transaction on getOrderWithItems ensures Room's two internal queries (one for OrderEntity, one for OrderItemEntity) run atomically.
  6. 6. @Relation parentColumn='id' entityColumn='order_id' — Room matches order.id to orderItem.order_id and groups items under their parent order automatically.
  7. 7. MIGRATION_2_3 uses ALTER TABLE — SQLite supports adding columns but not removing or renaming them (without rebuilding the table), so migrations must plan accordingly.
  8. 8. fallbackToDestructiveMigrationOnDowngrade() only triggers on version downgrade (reinstalling an older APK) — it does NOT affect normal upgrades.
  9. 9. insertOrderWithItems is annotated @Transaction and calls two @Insert DAOs — if the second insert fails, SQLite rolls back the first insert automatically.
  10. 10. inMemoryDatabaseBuilder creates a database backed by memory — all data is lost when the database is closed, making teardown trivial and tests fully isolated.

Spot the bug

// Find 4 bugs in this Room setup
@Entity(tableName = "employees")
data class EmployeeEntity(
    @PrimaryKey val id: String,
    val name: String,
    val department: Department,  // Bug 1
    val skills: List<String>     // Bug 2
)

@Dao
interface EmployeeDao {
    @Insert
    suspend fun insert(employee: EmployeeEntity)

    @Query("SELECT * FROM employees WHERE departement = :dept")  // Bug 3
    fun getByDepartment(dept: String): List<EmployeeEntity>      // Bug 4
}
Need a hint?
Look at unsupported field types, SQL typos, and missing reactive return type.
Show answer
Bug 1: Department is an enum (or custom class) — Room cannot store it directly. A @TypeConverter is needed to convert it to a String or Int for SQLite storage. Without a converter, Room throws a compile-time error: 'Cannot figure out how to save this field into database'. Bug 2: List<String> is not a SQLite-native type — same issue as Bug 1. A @TypeConverter that serialises/deserialises the list (e.g., as JSON) must be registered on the @Database with @TypeConverters(Converters::class). Bug 3: SQL typo — 'departement' has an extra 'e'. Room validates SQL at compile time against the actual schema, so this would be caught as a compile error if the column is named 'department'. However if you intended to store the enum as a string column named 'department', this typo in the query causes a compile failure. Fix: correct spelling to 'department'. Bug 4: getByDepartment() returns List<EmployeeEntity> (not a Flow or suspend fun) — this means Room will execute the query on whatever thread calls it. If called from the main thread, Room throws IllegalStateException ('Cannot access database on the main thread'). Fix: either make it suspend fun List<EmployeeEntity> for one-shot or Flow<List<EmployeeEntity>> for reactive updates.

Explain like I'm 5

Room is like a magical notebook for your app. You draw boxes on the pages (@Entity — like drawing a table). You write down rules for how to add, remove, and find things (@Dao — like a table of contents with instructions). When the notebook is old and you need more boxes, you write an upgrade note (Migration) so you don't rip out the old pages. And when you write on one page, anyone watching that page automatically sees the new writing right away (Flow).

Fun fact

Room's compile-time SQL verification works by running your @Query SQL against a virtual in-memory SQLite during the annotation processing phase of your build. If you have a typo in a column name, your app won't compile — not crash at runtime. This feature alone prevents a class of bugs that affected millions of users in the SQLite-direct era.

Hands-on challenge

Design a Room schema for a school attendance system: Student (id, name, classId), Class (id, name, teacherId), AttendanceRecord (id, studentId, date, status). Write the @Dao with: (1) a Flow query returning all students with their attendance for today, (2) a @Transaction function that marks an entire class as present atomically, (3) a Migration that adds a 'note' column to AttendanceRecord. Write the in-memory test for the transaction function.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery