AI News Hub Logo

AI News Hub

The Room Migration Mistake That Crashed Every User's App

DEV Community
SuriDevs

I crashed every existing user of my QR scanner app with one bad Room migration — added a label field, bumped the schema version, shipped, and watched the launch graph die. Below is everything I do now to avoid that, plus the entity/DAO/ViewModel patterns that turn Room from another framework chore into the fastest way to ship structured data on Android. I shipped my first QR scanner app without scan history. Users complained. Fair enough - scanning the same WiFi QR code every time is annoying. So I added history using raw SQLite. That was a mistake. Cursor management, forgetting to close database connections, SQL typos that only crashed in production. I spent more time debugging database code than building actual features. When I rewrote it with Room, the scan history feature took an afternoon instead of a week. Here's what fetching scan history looked like before Room: fun getAllScans(): List { val scans = mutableListOf() val db = dbHelper.readableDatabase var cursor: Cursor? = null try { cursor = db.rawQuery( "SELECT * FROM scan_history ORDER BY timestamp DESC", null ) while (cursor.moveToNext()) { scans.add( ScanRecord( id = cursor.getLong(cursor.getColumnIndexOrThrow("id")), content = cursor.getString(cursor.getColumnIndexOrThrow("content")), format = cursor.getString(cursor.getColumnIndexOrThrow("format")), timestamp = cursor.getLong(cursor.getColumnIndexOrThrow("timestamp")), isFavorite = cursor.getInt(cursor.getColumnIndexOrThrow("is_favorite")) == 1 ) ) } } finally { cursor?.close() } return scans } And here's the same thing with Room: @Query("SELECT * FROM scan_history ORDER BY timestamp DESC") fun getAllScans(): Flow> One line. The SQL is checked at compile time. If I typo a column name, the build fails instead of crashing on a user's phone. And it returns a Flow, so my UI updates automatically when data changes. Add these to your module's build.gradle.kts: plugins { id("com.google.devtools.ksp") } dependencies { val roomVersion = "2.6.1" implementation("androidx.room:room-runtime:$roomVersion") implementation("androidx.room:room-ktx:$roomVersion") ksp("androidx.room:room-compiler:$roomVersion") } Use KSP, not KAPT. It's faster and Google recommends it now. An Entity is just a data class that maps to a database table. For scan history: @Entity(tableName = "scan_history") data class ScanRecord( @PrimaryKey(autoGenerate = true) val id: Long = 0, val content: String, val format: String, val timestamp: Long, @ColumnInfo(name = "is_favorite") val isFavorite: Boolean = false, val label: String? = null ) Few things I learned: autoGenerate = true handles ID creation. Don't generate IDs yourself. @ColumnInfo lets you use snake_case in the database while keeping camelCase in Kotlin. I prefer this because SQL conventions use underscores. Nullable fields like label become nullable columns. Room handles this correctly. Default values work. New scans get isFavorite = false automatically. This is where you define database operations. I use suspend functions for one-shot operations and Flow for data I want to observe. @Dao interface ScanDao { @Query("SELECT * FROM scan_history ORDER BY timestamp DESC") fun getAllScans(): Flow> @Query("SELECT * FROM scan_history WHERE is_favorite = 1 ORDER BY timestamp DESC") fun getFavorites(): Flow> @Query("SELECT * FROM scan_history WHERE content LIKE '%' || :query || '%'") fun searchScans(query: String): Flow> @Insert suspend fun insert(scan: ScanRecord) @Query("UPDATE scan_history SET is_favorite = NOT is_favorite WHERE id = :scanId") suspend fun toggleFavorite(scanId: Long) @Query("DELETE FROM scan_history WHERE id = :scanId") suspend fun delete(scanId: Long) @Query("DELETE FROM scan_history WHERE timestamp > = scanDao.getAllScans() .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = emptyList() ) fun onScanCompleted(content: String, format: String) { viewModelScope.launch { scanDao.insert( ScanRecord( content = content, format = format, timestamp = System.currentTimeMillis() ) ) } } fun onToggleFavorite(scanId: Long) { viewModelScope.launch { scanDao.toggleFavorite(scanId) } } fun onDelete(scanId: Long) { viewModelScope.launch { scanDao.delete(scanId) } } } The WhileSubscribed(5000) keeps the Flow active for 5 seconds after the last subscriber disappears. This handles configuration changes - if the user rotates the screen, the Flow doesn't restart immediately. In your Composable, just collect the state: @Composable fun ScanHistoryScreen(viewModel: ScanHistoryViewModel = hiltViewModel()) { val scans by viewModel.scans.collectAsStateWithLifecycle() LazyColumn { items(scans, key = { it.id }) { scan -> ScanItem( scan = scan, onFavoriteClick = { viewModel.onToggleFavorite(scan.id) }, onDeleteClick = { viewModel.onDelete(scan.id) } ) } } } When you insert, delete, or update a scan, the UI updates automatically. No manual refresh needed. This is where I messed up badly once. I wanted to add a label field so users could add notes to scans. Simple change, right? I added the field to the Entity, bumped the version to 2, and shipped. The app crashed for every existing user. Room doesn't know how to transform version 1 to version 2 automatically. You have to tell it: val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE scan_history ADD COLUMN label TEXT") } } // In your database builder Room.databaseBuilder(context, ScanDatabase::class.java, "scan_database") .addMigrations(MIGRATION_1_2) .build() The migration SQL runs once when a user with version 1 opens the app after updating. Their data stays intact. You might see fallbackToDestructiveMigration() in tutorials. This deletes all user data when the schema changes. Fine for development, but never use it in production. Users will uninstall your app if their data disappears. Querying on the main thread. Room blocks this by default and crashes. If you see "Cannot access database on the main thread", you forgot to use a coroutine or background thread. All my DAO functions are either suspend or return Flow which handles threading automatically. Forgetting @Transaction. If you're doing multiple database operations that should succeed or fail together, wrap them: @Transaction suspend fun replaceAllScans(scans: List) { deleteAll() insertAll(scans) } Without @Transaction, a crash between delete and insert leaves the database empty. Not testing migrations. Room provides MigrationTestHelper for this. I didn't use it at first. Then I shipped a broken migration. Test your migrations. Indexing. If you're querying by a specific column frequently (like searching by format), add an index: @Entity( tableName = "scan_history", indices = [Index(value = ["format"])] ) I didn't need this for scan history since the dataset is small, but it matters for larger tables. Room is great for structured data with relationships. But sometimes it's overkill: Simple key-value pairs: Use DataStore instead. User preferences, settings, feature flags. Files or images: Store them in the filesystem, keep only the path in Room. Huge datasets with complex queries: Consider SQLDelight or direct SQLite if you need more control. For most apps though, Room handles everything you need. Room turned database code from my least favorite part of Android development into something I don't think about much anymore. It just works. The compile-time SQL checking alone has saved me from countless production bugs. Start simple - one Entity, one DAO, basic CRUD. Add complexity when you actually need it. You probably don't need FTS, triggers, or database views for your first feature. I certainly didn't for scan history. Originally published at suridevs.com — for more Android and Kotlin articles, browse the full blog.