androidengineers.Book a session

Modern Android Architecture Components

Room Architecture & Migrations

article25 minMedium

Room Overview

Room is Android's SQLite abstraction layer. It provides compile-time SQL verification, coroutine and Flow support, and structured migrations — replacing raw SQLiteOpenHelper for most use cases.

The three core annotations: @Database, @Entity, @Dao.


@Entity: Define Your Tables

@Entity(
    tableName = "articles",
    indices = [
        Index(value = ["author_id"]),           // speed up foreign key joins
        Index(value = ["slug"], unique = true)  // enforce uniqueness
    ],
    foreignKeys = [
        ForeignKey(
            entity = AuthorEntity::class,
            parentColumns = ["id"],
            childColumns = ["author_id"],
            onDelete = ForeignKey.CASCADE  // delete articles when author deleted
        )
    ]
)
data class ArticleEntity(
    @PrimaryKey val id: String,
    @ColumnInfo(name = "author_id") val authorId: String,
    val title: String,
    val slug: String,
    val body: String,
    @ColumnInfo(name = "published_at") val publishedAt: Long,
    @ColumnInfo(name = "is_bookmarked") val isBookmarked: Boolean = false
)

// Embedded: flatten a nested object into the same table row
data class Address(
    val street: String,
    val city: String,
    val country: String
)

@Entity(tableName = "users")
data class UserEntity(
    @PrimaryKey val id: String,
    val name: String,
    @Embedded(prefix = "home_") val homeAddress: Address?,
    @Embedded(prefix = "work_") val workAddress: Address?
)

@TypeConverter: Custom Column Types

Room doesn't know how to store arbitrary Kotlin types. @TypeConverter bridges the gap.

class Converters {
    // List<String> <-> JSON string
    @TypeConverter
    fun fromStringList(list: List<String>): String = Gson().toJson(list)

    @TypeConverter
    fun toStringList(json: String): List<String> =
        Gson().fromJson(json, object : TypeToken<List<String>>() {}.type)

    // Instant <-> Long
    @TypeConverter
    fun fromInstant(instant: Instant?): Long? = instant?.toEpochMilli()

    @TypeConverter
    fun toInstant(epoch: Long?): Instant? = epoch?.let { Instant.ofEpochMilli(it) }
}

// Register converters on the database
@Database(entities = [ArticleEntity::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun articleDao(): ArticleDao
}

@Dao: Queries with Coroutines and Flow

@Dao
interface ArticleDao {

    // Suspend functions: one-shot operations
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(articles: List<ArticleEntity>)

    @Update
    suspend fun update(article: ArticleEntity)

    @Delete
    suspend fun delete(article: ArticleEntity)

    @Query("DELETE FROM articles WHERE author_id = :authorId")
    suspend fun deleteByAuthor(authorId: String)

    // Flow: reactive queries — emits new value whenever data changes
    @Query("SELECT * FROM articles ORDER BY published_at DESC")
    fun observeAll(): Flow<List<ArticleEntity>>

    @Query("SELECT * FROM articles WHERE id = :id")
    fun observeById(id: String): Flow<ArticleEntity?>

    // Filtered query with parameters
    @Query("""
        SELECT * FROM articles 
        WHERE author_id = :authorId 
        AND is_bookmarked = :bookmarked 
        ORDER BY published_at DESC
        LIMIT :limit OFFSET :offset
    """)
    suspend fun getByAuthorPaged(
        authorId: String,
        bookmarked: Boolean = false,
        limit: Int = 20,
        offset: Int = 0
    ): List<ArticleEntity>

    // Partial update with @Query is more efficient than loading + updating
    @Query("UPDATE articles SET is_bookmarked = :bookmarked WHERE id = :id")
    suspend fun setBookmarked(id: String, bookmarked: Boolean)

    // Count
    @Query("SELECT COUNT(*) FROM articles WHERE author_id = :authorId")
    fun observeCountByAuthor(authorId: String): Flow<Int>
}

@Transaction: Multi-Table Consistency

Use @Transaction to wrap multiple operations in a single database transaction. Also required when a query returns a @Relation-annotated result.

// Relation: join two entities in Kotlin space (not SQL JOIN)
data class ArticleWithAuthor(
    @Embedded val article: ArticleEntity,
    @Relation(
        parentColumn = "author_id",
        entityColumn = "id"
    )
    val author: AuthorEntity
)

@Dao
interface ArticleDao {
    // @Transaction required for @Relation queries
    @Transaction
    @Query("SELECT * FROM articles WHERE id = :id")
    fun observeArticleWithAuthor(id: String): Flow<ArticleWithAuthor?>

    // Atomic write: delete old, insert new
    @Transaction
    suspend fun replaceAll(articles: List<ArticleEntity>) {
        deleteAll()
        insertAll(articles)
    }

    @Query("DELETE FROM articles")
    suspend fun deleteAll()
}

Database Setup

@Database(
    entities = [ArticleEntity::class, AuthorEntity::class, RemoteKeyEntity::class],
    version = 2,
    exportSchema = true  // generates schema JSON files — check into version control!
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun articleDao(): ArticleDao
    abstract fun authorDao(): AuthorDao
    abstract fun remoteKeyDao(): RemoteKeyDao

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                INSTANCE ?: Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app_database"
                )
                    .addMigrations(MIGRATION_1_2)
                    .build()
                    .also { INSTANCE = it }
            }
        }
    }
}

With Hilt:

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
        Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
            .addMigrations(MIGRATION_1_2)
            .build()

    @Provides
    fun provideArticleDao(db: AppDatabase): ArticleDao = db.articleDao()
}

Migrations

Never ship a database version bump without a migration. fallbackToDestructiveMigration() deletes all user data — only acceptable in development.

// Migration from version 1 to version 2: add a column
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // SQLite doesn't support ALTER COLUMN — only ADD COLUMN
        database.execSQL("""
            ALTER TABLE articles 
            ADD COLUMN read_count INTEGER NOT NULL DEFAULT 0
        """)
    }
}

// Migration 2 to 3: rename table (requires recreate in SQLite)
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // SQLite can't rename columns/tables in older Android versions
        // Create new table, copy data, drop old
        database.execSQL("""
            CREATE TABLE articles_new (
                id TEXT PRIMARY KEY NOT NULL,
                title TEXT NOT NULL,
                body TEXT NOT NULL,
                author_id TEXT NOT NULL,
                slug TEXT NOT NULL,
                published_at INTEGER NOT NULL,
                is_bookmarked INTEGER NOT NULL DEFAULT 0,
                read_count INTEGER NOT NULL DEFAULT 0
            )
        """)
        database.execSQL("""
            INSERT INTO articles_new SELECT * FROM articles
        """)
        database.execSQL("DROP TABLE articles")
        database.execSQL("ALTER TABLE articles_new RENAME TO articles")
    }
}

// Multi-step migration path (Room handles skipped versions automatically)
Room.databaseBuilder(...)
    .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
    .build()

Testing

In-Memory Database (Unit-level)

@RunWith(AndroidJUnit4::class)
class ArticleDaoTest {

    private lateinit var db: AppDatabase
    private lateinit var dao: ArticleDao

    @Before
    fun setup() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java
        )
            .allowMainThreadQueries() // only in tests
            .build()
        dao = db.articleDao()
    }

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

    @Test
    fun insertAndRetrieve() = runTest {
        val article = ArticleEntity(
            id = "1", authorId = "a1", title = "Test", slug = "test",
            body = "Body", publishedAt = 1000L
        )
        dao.insertAll(listOf(article))
        val all = dao.observeAll().first()
        assertEquals(1, all.size)
        assertEquals("Test", all.first().title)
    }

    @Test
    fun bookmarkUpdatesCorrectly() = runTest {
        dao.insertAll(listOf(article))
        dao.setBookmarked("1", true)
        val result = dao.observeAll().first()
        assertTrue(result.first().isBookmarked)
    }
}

MigrationTestHelper

@RunWith(AndroidJUnit4::class)
class MigrationTest {

    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java
    )

    @Test
    fun migrate1to2() {
        // Create database at version 1
        helper.createDatabase("test_db", 1).apply {
            execSQL("INSERT INTO articles VALUES ('1', 'a1', 'Title', 'slug', 'Body', 1000, 0)")
            close()
        }

        // Run migration and validate
        val db = helper.runMigrationsAndValidate("test_db", 2, true, MIGRATION_1_2)
        val cursor = db.query("SELECT read_count FROM articles WHERE id = '1'")
        cursor.moveToFirst()
        assertEquals(0, cursor.getInt(0)) // default value applied
        cursor.close()
    }
}

Key Takeaways

ConceptSummary
@EntityMaps Kotlin class to a SQLite table
@TypeConverterBridge between Room-supported types and custom types
@DaoSQL queries as suspend functions or Flow returning functions
Flow<T> from DAORe-emits when underlying data changes; observe in ViewModel
@TransactionRequired for @Relation queries; ensures atomic multi-step writes
exportSchema = trueSchema JSON in version control enables migration review
MigrationsAlways explicit; fallbackToDestructiveMigration() deletes user data
MigrationTestHelperInstrumented test to verify migrations don't corrupt data
In-memory DBFast unit tests for DAO logic

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Room Architecture & Migrations | Android System Design | Android Engineers