n, DAO efficiency, migration safety, and integration with the application's reactive architecture.
1. Architecture and Dependencies
Room should be integrated within a Repository pattern that abstracts the data source from the ViewModel. This allows for seamless switching between local and remote data sources and facilitates testing.
Gradle Configuration:
plugins {
id("com.google.devtools.ksp") // Kotlin Symbol Processing
}
dependencies {
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion") // Flow support
implementation("androidx.room:room-paging:$roomVersion") // Paging 3 integration
ksp("androidx.room:room-compiler:$roomVersion")
// Testing
testImplementation("androidx.room:room-testing:$roomVersion")
}
2. Entity Design and Type Safety
Entities must be immutable data classes where possible. Use @ColumnInfo to decouple Kotlin property names from SQLite column names, allowing refactoring without breaking migrations.
@Entity(
tableName = "users",
indices = [
Index(value = ["email"], unique = true),
Index(value = ["last_active_timestamp"])
]
)
data class User(
@PrimaryKey val id: String,
@ColumnInfo(name = "email") val email: String,
@ColumnInfo(name = "profile_data") val profileData: ProfileData,
@ColumnInfo(name = "last_active_timestamp") val lastActive: Long
)
// Custom object requiring TypeConverter
data class ProfileData(val avatarUrl: String, val bio: String)
TypeConverter Implementation:
Room cannot store complex objects directly. A TypeConverter serializes/deserializes the object. Use Moshi or Gson, or manual JSON parsing for zero-dependency solutions.
class Converters {
@TypeConverter
fun fromProfileData(profileData: ProfileData?): String? {
return profileData?.let { Json.encodeToString(it) }
}
@TypeConverter
fun toProfileData(json: String?): ProfileData? {
return json?.let { Json.decodeFromString(it) }
}
}
3. DAO Patterns and Reactive Streams
Data Access Objects should expose Kotlin Flow rather than LiveData to align with modern coroutines and structured concurrency. Use @Query for complex reads and @Insert/@Update/@Delete for mutations.
@Dao
interface UserDao {
@Query("SELECT * FROM users WHERE id = :userId")
fun getUserById(userId: String): Flow<User?>
@Query("SELECT * FROM users ORDER BY last_active_timestamp DESC LIMIT :limit")
fun getActiveUsers(limit: Int): Flow<List<User>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertUser(user: User)
@Query("DELETE FROM users WHERE last_active_timestamp < :cutoff")
suspend fun pruneInactiveUsers(cutoff: Long)
}
Key Decision: OnConflictStrategy.REPLACE is preferred for upsert operations in sync scenarios, as it handles updates atomically. OnConflictStrategy.IGNORE is suitable when you want to preserve existing data and skip duplicates.
4. Database Singleton and Migration Strategy
The RoomDatabase instance must be a singleton. Multiple instances can cause locking issues and memory leaks. Migrations must be versioned and tested.
@Database(
entities = [User::class],
version = 2,
exportSchema = true // Critical for migration testing
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
)
.addMigrations(MIGRATION_1_2)
.setQueryExecutor(Executors.newSingleThreadExecutor()) // Dedicated query thread
.addCallback(object : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
// Pre-populate data if necessary
}
})
.build()
.also { INSTANCE = it }
}
}
}
}
Migration Implementation:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
// Example: Add a new column with a default value
database.execSQL(
"ALTER TABLE users ADD COLUMN last_active_timestamp INTEGER NOT NULL DEFAULT 0"
)
}
}
5. Repository Integration
The repository encapsulates the database logic and provides a clean API to the ViewModel.
class UserRepository @Inject constructor(private val userDao: UserDao) {
fun getUser(userId: String): Flow<User?> = userDao.getUserById(userId)
suspend fun saveUser(user: User) {
userDao.upsertUser(user)
}
}
Pitfall Guide
Production experience reveals recurring patterns of failure in Room implementations. Avoid these pitfalls to ensure stability and performance.
-
Main Thread Violations:
- Mistake: Using
allowMainThreadQueries() in production builds to bypass Room's default check.
- Consequence: UI freezes, ANRs, and dropped frames. Room blocks the main thread by design; disabling this removes a critical safety guardrail.
- Fix: Always use
suspend functions or Flow. If synchronous access is absolutely required, wrap it in withContext(Dispatchers.IO).
-
N+1 Query Problem:
- Mistake: Fetching a list of items in a loop and querying related data for each item individually.
- Consequence: Exponential increase in query time and database lock contention.
- Fix: Use SQL
JOIN operations or @Relation annotations to fetch related data in a single query. For complex relations, consider flattening data into DTOs using @Query projections.
-
Migration Version Skew:
- Mistake: Incrementing the version number but forgetting to add the
Migration object to the builder, or writing a migration that only works for a specific previous version.
- Consequence:
IllegalStateException for users who skipped versions. Room requires a path from every previous version to the current version.
- Fix: Implement migrations for all version hops (e.g., 1->2, 1->3, 2->3 if jumping to 3). Use
exportSchema = true and test migrations using Room.inMemoryDatabaseBuilder with schema files.
-
Ignoring Indices:
- Mistake: Relying on full table scans for frequent queries, especially on columns used in
WHERE, ORDER BY, or JOIN clauses.
- Consequence: Query performance degrades linearly with data size. A query on 100 rows might take 1ms, but on 100k rows it could take seconds.
- Fix: Define
@Index on frequently queried columns. Monitor query performance using the Android Studio Database Inspector.
-
Overusing @Relation:
- Mistake: Creating deep object graphs with multiple
@Relation annotations that load massive amounts of data when only a subset is needed.
- Consequence: High memory usage and slow UI rendering.
- Fix: Use
@Relation only when the full graph is required. For partial data, write custom @Query methods that return specific columns or lightweight data classes.
-
TypeConverter Errors:
- Mistake: Throwing exceptions inside
TypeConverter methods or failing to handle null values.
- Consequence: Runtime crashes during database access if data is malformed or missing.
- Fix: Ensure converters handle
null gracefully and log errors without crashing. Validate data integrity at the application layer before insertion.
-
Leaking Database Instances:
- Mistake: Creating multiple instances of
RoomDatabase in different parts of the app.
- Consequence: Database locking exceptions and increased memory footprint.
- Fix: Use a singleton pattern or dependency injection (Hilt/Dagger) to provide a single instance throughout the application lifecycle.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple Key-Value Settings | DataStore | Lightweight, type-safe, no schema overhead. | Low implementation cost. |
| Complex Relational Data | Room | SQL power, indexing, complex queries, migrations. | Higher initial setup, lower long-term maintenance. |
| Large Binary Files | File System | Room is not optimized for BLOBs > 1MB. | Reduces DB size, improves backup efficiency. |
| Search Functionality | Room FTS (@Fts4) | Full-text search optimized for text matching. | Adds index size, but drastically improves search UX. |
| Paging Large Lists | Room + Paging 3 | Efficient loading of subsets of data. | Reduces memory usage and improves scroll performance. |
Configuration Template
Copy this template to initialize a production-grade Room database with Hilt integration.
// DatabaseModule.kt
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"production_database"
)
.addMigrations(
MIGRATION_1_2,
MIGRATION_2_3
)
.setQueryExecutor(Executors.newSingleThreadExecutor())
.addCallback(object : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
// Prepopulate logic here
}
})
.build()
}
@Provides
@Singleton
fun provideUserDao(database: AppDatabase): UserDao {
return database.userDao()
}
}
Quick Start Guide
- Add Dependencies: Include
room-runtime, room-ktx, and ksp plugin in your build.gradle.kts.
- Define Entity: Create a data class annotated with
@Entity, define @PrimaryKey, and add @Index for query performance.
- Create DAO: Define an interface with
@Dao, write @Query methods returning Flow, and @Insert/@Update methods as suspend functions.
- Build Database: Create an abstract class extending
RoomDatabase, add @Database annotation with entities and version, and implement a singleton getter with migrations.
- Integrate Repository: Inject the DAO into a Repository class, expose
Flow to the ViewModel, and collect data in the UI layer using collectLatest in a coroutine scope.