Introduction

Room has become the de facto standard for local data persistence on Android. Built on SQLite with compile-time verification, type-safe queries, and seamless Kotlin coroutines integration, Room handles everything from simple key-value storage to complex relational data.

This guide covers battle-tested patterns for production Room implementations. We will explore entity design, relationship modelling, migration strategies, performance optimization, and testing approaches that scale.

Entity Design Fundamentals

Basi

c Entity Structure

import androidx.room.*
import java.time.Instant
import java.util.UUID

@Entity(
    tableName = "tasks",
    indices = [
        Index(value = ["project_id"]),
        Index(value = ["due_date"]),
        Index(value = ["is_completed", "due_date"])
    ]
)
data class TaskEntity(
    @PrimaryKey
    @ColumnInfo(name = "id")
    val id: String = UUID.randomUUID().toString(),

    @ColumnInfo(name = "title")
    val title: String,

    @ColumnInfo(name = "description")
    val description: String? = null,

    @ColumnInfo(name = "is_completed", defaultValue = "0")
    val isCompleted: Boolean = false,

    @ColumnInfo(name = "priority", defaultValue = "1")
    val priority: Priority = Priority.MEDIUM,

    @ColumnInfo(name = "due_date")
    val dueDate: Instant? = null,

    @ColumnInfo(name = "project_id")
    val projectId: String? = null,

    @ColumnInfo(name = "created_at")
    val createdAt: Instant = Instant.now(),

    @ColumnInfo(name = "updated_at")
    val updatedAt: Instant = Instant.now()
) {
    enum class Priority(val value: Int) {
        LOW(0),
        MEDIUM(1),
        HIGH(2),
        URGENT(3)
    }
}

Type Converters

import androidx.room.TypeConverter
import java.time.Instant

class Converters {

    // Instant converters
    @TypeConverter
    fun fromInstant(instant: Instant?): Long? {
        return instant?.toEpochMilli()
    }

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

    // Priority enum converter
    @TypeConverter
    fun fromPriority(priority: TaskEntity.Priority): Int {
        return priority.value
    }

    @TypeConverter
    fun toPriority(value: Int): TaskEntity.Priority {
        return TaskEntity.Priority.entries.find { it.value == value }
            ?: TaskEntity.Priority.MEDIUM
    }

    // List<String> converter for tags
    @TypeConverter
    fun fromStringList(list: List<String>?): String? {
        return list?.joinToString(separator = "|||")
    }

    @TypeConverter
    fun toStringList(data: String?): List<String>? {
        return data?.split("|||")?.filter { it.isNotEmpty() }
    }

    // JSON converter for complex objects
    @TypeConverter
    fun fromMetadata(metadata: TaskMetadata?): String? {
        return metadata?.let { Json.encodeToString(it) }
    }

    @TypeConverter
    fun toMetadata(json: String?): TaskMetadata? {
        return json?.let {
            try {
                Json.decodeFromString<TaskMetadata>(it)
            } catch (e: Exception) {
                null
            }
        }
    }
}

@Serializable
data class TaskMetadata(
    val attachmentCount: Int = 0,
    val commentCount: Int = 0,
    val lastViewedAt: Long? = null,
    val customFields: Map<String, String> = emptyMap()
)

Relationship Modelling

One-to-Many Relationships

@Entity(tableName = "projects")
data class ProjectEntity(
    @PrimaryKey
    val id: String = UUID.randomUUID().toString(),
    val name: String,
    val color: String = "#007AFF",
    val createdAt: Instant = Instant.now()
)

// Relationship class for queries
data class ProjectWithTasks(
    @Embedded
    val project: ProjectEntity,

    @Relation(
        parentColumn = "id",
        entityColumn = "project_id"
    )
    val tasks: List<TaskEntity>
)

// DAO method to fetch
@Dao
interface ProjectDao {

    @Transaction
    @Query("SELECT * FROM projects WHERE id = :projectId")
    suspend fun getProjectWithTasks(projectId: String): ProjectWithTasks?

    @Transaction
    @Query("SELECT * FROM projects ORDER BY name ASC")
    fun observeAllProjectsWithTasks(): Flow<List<ProjectWithTasks>>
}

Many-to-Many Relationships

@Entity(tableName = "tags")
data class TagEntity(
    @PrimaryKey
    val id: String = UUID.randomUUID().toString(),
    val name: String,
    val color: String = "#808080"
)

// Junction table
@Entity(
    tableName = "task_tag_cross_ref",
    primaryKeys = ["task_id", "tag_id"],
    foreignKeys = [
        ForeignKey(
            entity = TaskEntity::class,
            parentColumns = ["id"],
            childColumns = ["task_id"],
            onDelete = ForeignKey.CASCADE
        ),
        ForeignKey(
            entity = TagEntity::class,
            parentColumns = ["id"],
            childColumns = ["tag_id"],
            onDelete = ForeignKey.CASCADE
        )
    ],
    indices = [
        Index(value = ["tag_id"])
    ]
)
data class TaskTagCrossRef(
    @ColumnInfo(name = "task_id")
    val taskId: String,
    @ColumnInfo(name = "tag_id")
    val tagId: String
)

// Task with tags
data class TaskWithTags(
    @Embedded
    val task: TaskEntity,

    @Relation(
        parentColumn = "id",
        entityColumn = "id",
        associateBy = Junction(
            value = TaskTagCrossRef::class,
            parentColumn = "task_id",
            entityColumn = "tag_id"
        )
    )
    val tags: List<TagEntity>
)

// Tag with tasks
data class TagWithTasks(
    @Embedded
    val tag: TagEntity,

    @Relation(
        parentColumn = "id",
        entityColumn = "id",
        associateBy = Junction(
            value = TaskTagCrossRef::class,
            parentColumn = "tag_id",
            entityColumn = "task_id"
        )
    )
    val tasks: List<TaskEntity>
)

@Dao
interface TaskTagDao {

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insertTaskTagCrossRef(crossRef: TaskTagCrossRef)

    @Delete
    suspend fun deleteTaskTagCrossRef(crossRef: TaskTagCrossRef)

    @Transaction
    @Query("SELECT * FROM tasks WHERE id = :taskId")
    suspend fun getTaskWithTags(taskId: String): TaskWithTags?

    @Transaction
    @Query("SELECT * FROM tags WHERE id = :tagId")
    fun observeTagWithTasks(tagId: String): Flow<TagWithTasks?>

    // Add tag to task
    @Transaction
    suspend fun addTagToTask(taskId: String, tagId: String) {
        insertTaskTagCrossRef(TaskTagCrossRef(taskId, tagId))
    }

    // Remove tag from task
    @Transaction
    suspend fun removeTagFromTask(taskId: String, tagId: String) {
        deleteTaskTagCrossRef(TaskTagCrossRef(taskId, tagId))
    }
}

Advanced DAO Patterns

Advanced DAO Patterns Infographic Efficient Queries with Projections

// Only fetch what you need
data class TaskSummary(
    val id: String,
    val title: String,
    @ColumnInfo(name = "is_completed")
    val isCompleted: Boolean,
    @ColumnInfo(name = "due_date")
    val dueDate: Instant?
)

@Dao
interface TaskDao {

    // Full entity when editing
    @Query("SELECT * FROM tasks WHERE id = :taskId")
    suspend fun getTaskById(taskId: String): TaskEntity?

    // Lightweight projection for lists
    @Query("""
        SELECT id, title, is_completed, due_date
        FROM tasks
        WHERE project_id = :projectId
        ORDER BY due_date ASC NULLS LAST
    """)
    fun observeTaskSummaries(projectId: String): Flow<List<TaskSummary>>

    // Count queries are fast
    @Query("SELECT COUNT(*) FROM tasks WHERE is_completed = 0")
    fun observePendingTaskCount(): Flow<Int>

    // Aggregations
    @Query("""
        SELECT project_id, COUNT(*) as task_count
        FROM tasks
        WHERE is_completed = 0
        GROUP BY project_id
    """)
    fun observeTaskCountsByProject(): Flow<List<ProjectTaskCount>>
}

data class ProjectTaskCount(
    @ColumnInfo(name = "project_id")
    val projectId: String?,
    @ColumnInfo(name = "task_count")
    val taskCount: Int
)

Pagination with Paging 3

@Dao
interface TaskDao {

    // PagingSource for Paging 3
    @Query("""
        SELECT * FROM tasks
        WHERE is_completed = :showCompleted
        ORDER BY
            CASE WHEN due_date IS NULL THEN 1 ELSE 0 END,
            due_date ASC,
            priority DESC
    """)
    fun getTasksPagingSource(showCompleted: Boolean): PagingSource<Int, TaskEntity>

    // With search
    @Query("""
        SELECT * FROM tasks
        WHERE (title LIKE '%' || :query || '%' OR description LIKE '%' || :query || '%')
        ORDER BY
            CASE WHEN title LIKE :query || '%' THEN 0
                 WHEN title LIKE '%' || :query || '%' THEN 1
                 ELSE 2 END,
            created_at DESC
    """)
    fun searchTasksPagingSource(query: String): PagingSource<Int, TaskEntity>
}

// Repository implementation
class TaskRepository(
    private val taskDao: TaskDao
) {

    fun getTasksPager(showCompleted: Boolean): Flow<PagingData<TaskEntity>> {
        return Pager(
            config = PagingConfig(
                pageSize = 20,
                enablePlaceholders = false,
                prefetchDistance = 5
            ),
            pagingSourceFactory = { taskDao.getTasksPagingSource(showCompleted) }
        ).flow
    }

    fun searchTasks(query: String): Flow<PagingData<TaskEntity>> {
        return Pager(
            config = PagingConfig(
                pageSize = 20,
                enablePlaceholders = false
            ),
            pagingSourceFactory = { taskDao.searchTasksPagingSource(query) }
        ).flow
    }
}

// ViewModel usage
@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val repository: TaskRepository
) : ViewModel() {

    private val _showCompleted = MutableStateFlow(false)

    val tasks: Flow<PagingData<TaskEntity>> = _showCompleted
        .flatMapLatest { showCompleted ->
            repository.getTasksPager(showCompleted)
        }
        .cachedIn(viewModelScope)
}

Batch Operations

@Dao
interface TaskDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(tasks: List<TaskEntity>)

    @Query("UPDATE tasks SET is_completed = 1, updated_at = :now WHERE id IN (:taskIds)")
    suspend fun markTasksCompleted(taskIds: List&lt;String&gt;, now: Instant = Instant.now())

    @Query("DELETE FROM tasks WHERE is_completed = 1 AND updated_at < :cutoff")
    suspend fun deleteOldCompletedTasks(cutoff: Instant): Int

    @Query("UPDATE tasks SET project_id = :newProjectId WHERE project_id = :oldProjectId")
    suspend fun moveTasksToProject(oldProjectId: String, newProjectId: String)

    // Upsert pattern
    @Transaction
    suspend fun upsertTask(task: TaskEntity) {
        val existing = getTaskById(task.id)
        if (existing != null) {
            updateTask(task.copy(updatedAt = Instant.now()))
        } else {
            insertTask(task)
        }
    }

    @Insert
    suspend fun insertTask(task: TaskEntity)

    @Update
    suspend fun updateTask(task: TaskEntity)
}

Database Migrations

Migration Strategy

@Database(
    entities = [
        TaskEntity::class,
        ProjectEntity::class,
        TagEntity::class,
        TaskTagCrossRef::class
    ],
    version = 4,
    exportSchema = true
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {

    abstract fun taskDao(): TaskDao
    abstract fun projectDao(): ProjectDao
    abstract fun tagDao(): TagDao
    abstract fun taskTagDao(): TaskTagDao

    companion object {
        const val DATABASE_NAME = "task_manager.db"

        // Migration from version 1 to 2: Added priority column
        val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(db: SupportSQLiteDatabase) {
                db.execSQL("""
                    ALTER TABLE tasks
                    ADD COLUMN priority INTEGER NOT NULL DEFAULT 1
                """)
            }
        }

        // Migration from version 2 to 3: Added tags table and junction
        val MIGRATION_2_3 = object : Migration(2, 3) {
            override fun migrate(db: SupportSQLiteDatabase) {
                // Create tags table
                db.execSQL("""
                    CREATE TABLE IF NOT EXISTS tags (
                        id TEXT PRIMARY KEY NOT NULL,
                        name TEXT NOT NULL,
                        color TEXT NOT NULL DEFAULT '#808080'
                    )
                """)

                // Create junction table
                db.execSQL("""
                    CREATE TABLE IF NOT EXISTS task_tag_cross_ref (
                        task_id TEXT NOT NULL,
                        tag_id TEXT NOT NULL,
                        PRIMARY KEY (task_id, tag_id),
                        FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
                        FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
                    )
                """)

                // Create index
                db.execSQL("""
                    CREATE INDEX IF NOT EXISTS index_task_tag_cross_ref_tag_id
                    ON task_tag_cross_ref(tag_id)
                """)
            }
        }

        // Migration from version 3 to 4: Added updated_at column
        val MIGRATION_3_4 = object : Migration(3, 4) {
            override fun migrate(db: SupportSQLiteDatabase) {
                // Add column with current timestamp as default
                val now = System.currentTimeMillis()
                db.execSQL("""
                    ALTER TABLE tasks
                    ADD COLUMN updated_at INTEGER NOT NULL DEFAULT $now
                """)
            }
        }

        // Destructive migration fallback for development
        private val DESTRUCTIVE_MIGRATION_CALLBACK = object : Callback() {
            override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
                super.onDestructiveMigration(db)
                // Log or report this occurrence
                Log.w("AppDatabase", "Destructive migration occurred!")
            }
        }
    }
}

Database Builder

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

    @Provides
    @Singleton
    fun provideDatabase(
        @ApplicationContext context: Context
    ): AppDatabase {
        return Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            AppDatabase.DATABASE_NAME
        )
            .addMigrations(
                AppDatabase.MIGRATION_1_2,
                AppDatabase.MIGRATION_2_3,
                AppDatabase.MIGRATION_3_4
            )
            // Only use in debug builds
            .apply {
                if (BuildConfig.DEBUG) {
                    fallbackToDestructiveMigration()
                }
            }
            .addCallback(object : RoomDatabase.Callback() {
                override fun onCreate(db: SupportSQLiteDatabase) {
                    super.onCreate(db)
                    // Seed initial data if needed
                }
            })
            .build()
    }

    @Provides
    fun provideTaskDao(database: AppDatabase): TaskDao = database.taskDao()

    @Provides
    fun provideProjectDao(database: AppDatabase): ProjectDao = database.projectDao()
}

Testing Migrations

@RunWith(AndroidJUnit4::class)
class MigrationTest {

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

    @Test
    fun migrate1To2() {
        // Create database at version 1
        var db = helper.createDatabase(TEST_DB, 1).apply {
            execSQL("""
                INSERT INTO tasks (id, title, is_completed, created_at)
                VALUES ('task-1', 'Test Task', 0, ${System.currentTimeMillis()})
            """)
            close()
        }

        // Re-open with migration
        db = helper.runMigrationsAndValidate(TEST_DB, 2, true, AppDatabase.MIGRATION_1_2)

        // Verify migration
        val cursor = db.query("SELECT * FROM tasks WHERE id = 'task-1'")
        cursor.moveToFirst()

        val priorityIndex = cursor.getColumnIndex("priority")
        assertThat(cursor.getInt(priorityIndex)).isEqualTo(1) // Default medium

        cursor.close()
        db.close()
    }

    @Test
    fun migrate2To3() {
        helper.createDatabase(TEST_DB, 2).close()

        val db = helper.runMigrationsAndValidate(TEST_DB, 3, true, AppDatabase.MIGRATION_2_3)

        // Verify tags table exists
        val cursor = db.query("SELECT * FROM tags")
        assertThat(cursor.columnCount).isGreaterThan(0)

        cursor.close()
        db.close()
    }

    @Test
    fun migrateAllVersions() {
        helper.createDatabase(TEST_DB, 1).close()

        helper.runMigrationsAndValidate(
            TEST_DB,
            4,
            true,
            AppDatabase.MIGRATION_1_2,
            AppDatabase.MIGRATION_2_3,
            AppDatabase.MIGRATION_3_4
        )
    }

    companion object {
        private const val TEST_DB = "migration-test"
    }
}

Performance Optimization

Index Strategy

@Entity(
    tableName = "tasks",
    indices = [
        // Single column indices for common filters
        Index(value = ["project_id"]),
        Index(value = ["is_completed"]),

        // Composite index for common query patterns
        Index(value = ["is_completed", "due_date"]),
        Index(value = ["project_id", "is_completed"]),

        // Covering index for list queries (includes all needed columns)
        Index(value = ["is_completed", "due_date", "priority", "title"])
    ]
)
data class TaskEntity(...)

Query Optimization

@Dao
interface TaskDao {

    // BAD: Fetches all columns, all rows
    @Query("SELECT * FROM tasks")
    suspend fun getAllTasks(): List<TaskEntity>

    // GOOD: Projection + filtering + limiting
    @Query("""
        SELECT id, title, is_completed, due_date
        FROM tasks
        WHERE is_completed = 0
        ORDER BY due_date ASC NULLS LAST
        LIMIT :limit
    """)
    suspend fun getPendingTaskSummaries(limit: Int = 50): List<TaskSummary>

    // BAD: LIKE with leading wildcard prevents index use
    @Query("SELECT * FROM tasks WHERE title LIKE '%' || :query || '%'")
    suspend fun searchTasksSlow(query: String): List<TaskEntity>

    // BETTER: Use FTS for full-text search
    @Query("SELECT * FROM tasks WHERE id IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH :query)")
    suspend fun searchTasksFts(query: String): List<TaskEntity>
}

// Full-text search table
@Fts4(contentEntity = TaskEntity::class)
@Entity(tableName = "tasks_fts")
data class TaskFts(
    val title: String,
    val description: String?
)

Background Operations

class TaskRepository(
    private val taskDao: TaskDao,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {

    // Heavy operations on IO dispatcher
    suspend fun importTasks(tasks: List<TaskEntity>) = withContext(ioDispatcher) {
        // Batch insert for performance
        tasks.chunked(100).forEach { batch ->
            taskDao.insertAll(batch)
        }
    }

    // Cleanup old data in background
    suspend fun cleanupOldTasks() = withContext(ioDispatcher) {
        val cutoff = Instant.now().minus(30, ChronoUnit.DAYS)
        val deletedCount = taskDao.deleteOldCompletedTasks(cutoff)
        Log.d("TaskRepository", "Deleted $deletedCount old completed tasks")
    }

    // Pre-warm cache on app start
    suspend fun preloadRecentTasks() = withContext(ioDispatcher) {
        // This query warms SQLite's page cache
        taskDao.getPendingTaskSummaries(limit = 100)
    }
}

Memory Management

// Use Flow with proper lifecycle management
@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val taskDao: TaskDao
) : ViewModel() {

    // Flow automatically cancels when ViewModel is cleared
    val tasks: StateFlow<List<TaskSummary>> = taskDao
        .observeTaskSummaries(projectId = "default")
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    // For large lists, use distinctUntilChanged to avoid unnecessary recomposition
    val taskCount: StateFlow<Int> = taskDao
        .observePendingTaskCount()
        .distinctUntilChanged()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = 0
        )
}

Testing Room

In-Memory Database for Unit Tests

@RunWith(AndroidJUnit4::class)
class TaskDaoTest {

    private lateinit var database: AppDatabase
    private lateinit var taskDao: TaskDao

    @Before
    fun setup() {
        database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java
        )
            .allowMainThreadQueries() // Only for tests
            .build()

        taskDao = database.taskDao()
    }

    @After
    fun teardown() {
        database.close()
    }

    @Test
    fun insertTask_retrievesCorrectly() = runTest {
        val task = TaskEntity(title = "Test Task")

        taskDao.insertTask(task)
        val retrieved = taskDao.getTaskById(task.id)

        assertThat(retrieved).isNotNull()
        assertThat(retrieved?.title).isEqualTo("Test Task")
    }

    @Test
    fun updateTask_modifiesExisting() = runTest {
        val task = TaskEntity(title = "Original")
        taskDao.insertTask(task)

        taskDao.updateTask(task.copy(title = "Updated"))
        val retrieved = taskDao.getTaskById(task.id)

        assertThat(retrieved?.title).isEqualTo("Updated")
    }

    @Test
    fun deleteTask_removesFromDatabase() = runTest {
        val task = TaskEntity(title = "To Delete")
        taskDao.insertTask(task)

        taskDao.deleteTask(task)
        val retrieved = taskDao.getTaskById(task.id)

        assertThat(retrieved).isNull()
    }

    @Test
    fun observePendingTasks_emitsUpdates() = runTest {
        val results = mutableListOf<List<TaskSummary>>()

        val job = launch {
            taskDao.observeTaskSummaries("default").take(3).collect {
                results.add(it)
            }
        }

        // Initial empty state
        advanceUntilIdle()

        // Insert task
        val task = TaskEntity(title = "New Task", projectId = "default")
        taskDao.insertTask(task)
        advanceUntilIdle()

        // Complete task
        taskDao.markTasksCompleted(listOf(task.id))
        advanceUntilIdle()

        job.cancel()

        assertThat(results).hasSize(3)
        assertThat(results[0]).isEmpty()
        assertThat(results[1]).hasSize(1)
    }

    @Test
    fun relationshipQuery_returnsNestedData() = runTest {
        // Create project
        val project = ProjectEntity(name = "Test Project")
        database.projectDao().insertProject(project)

        // Create tasks
        val task1 = TaskEntity(title = "Task 1", projectId = project.id)
        val task2 = TaskEntity(title = "Task 2", projectId = project.id)
        taskDao.insertTask(task1)
        taskDao.insertTask(task2)

        // Query with relationship
        val projectWithTasks = database.projectDao().getProjectWithTasks(project.id)

        assertThat(projectWithTasks).isNotNull()
        assertThat(projectWithTasks?.tasks).hasSize(2)
    }
}

Repository Tests with Fake DAO

class FakeTaskDao : TaskDao {

    private val tasks = mutableMapOf<String, TaskEntity>()
    private val tasksFlow = MutableStateFlow<List<TaskEntity>>(emptyList())

    override suspend fun insertTask(task: TaskEntity) {
        tasks[task.id] = task
        updateFlow()
    }

    override suspend fun updateTask(task: TaskEntity) {
        tasks[task.id] = task
        updateFlow()
    }

    override suspend fun deleteTask(task: TaskEntity) {
        tasks.remove(task.id)
        updateFlow()
    }

    override suspend fun getTaskById(taskId: String): TaskEntity? {
        return tasks[taskId]
    }

    override fun observeTaskSummaries(projectId: String): Flow<List<TaskSummary>> {
        return tasksFlow.map { allTasks ->
            allTasks
                .filter { it.projectId == projectId }
                .map { TaskSummary(it.id, it.title, it.isCompleted, it.dueDate) }
        }
    }

    private fun updateFlow() {
        tasksFlow.value = tasks.values.toList()
    }
}

class TaskRepositoryTest {

    private lateinit var fakeDao: FakeTaskDao
    private lateinit var repository: TaskRepository

    @Before
    fun setup() {
        fakeDao = FakeTaskDao()
        repository = TaskRepository(fakeDao)
    }

    @Test
    fun `createTask adds task to database`() = runTest {
        val task = TaskEntity(title = "New Task")

        repository.createTask(task)

        assertThat(fakeDao.getTaskById(task.id)).isNotNull()
    }
}

Common Patterns

Repository Pattern with Domain Models

// Domain model (clean, no Room annotations)
data class Task(
    val id: String,
    val title: String,
    val description: String?,
    val isCompleted: Boolean,
    val priority: Priority,
    val dueDate: Instant?,
    val projectId: String?,
    val tags: List<Tag>
) {
    enum class Priority { LOW, MEDIUM, HIGH, URGENT }
}

// Mapper
fun TaskWithTags.toDomain(): Task {
    return Task(
        id = task.id,
        title = task.title,
        description = task.description,
        isCompleted = task.isCompleted,
        priority = when (task.priority) {
            TaskEntity.Priority.LOW -> Task.Priority.LOW
            TaskEntity.Priority.MEDIUM -> Task.Priority.MEDIUM
            TaskEntity.Priority.HIGH -> Task.Priority.HIGH
            TaskEntity.Priority.URGENT -> Task.Priority.URGENT
        },
        dueDate = task.dueDate,
        projectId = task.projectId,
        tags = tags.map { it.toDomain() }
    )
}

fun Task.toEntity(): TaskEntity {
    return TaskEntity(
        id = id,
        title = title,
        description = description,
        isCompleted = isCompleted,
        priority = when (priority) {
            Task.Priority.LOW -> TaskEntity.Priority.LOW
            Task.Priority.MEDIUM -> TaskEntity.Priority.MEDIUM
            Task.Priority.HIGH -> TaskEntity.Priority.HIGH
            Task.Priority.URGENT -> TaskEntity.Priority.URGENT
        },
        dueDate = dueDate,
        projectId = projectId
    )
}

// Repository exposes domain models
class TaskRepository(
    private val taskDao: TaskDao,
    private val taskTagDao: TaskTagDao
) {

    fun observeTasks(projectId: String): Flow<List<Task>> {
        return taskTagDao.observeTasksWithTags(projectId)
            .map { entities -> entities.map { it.toDomain() } }
    }

    suspend fun getTask(taskId: String): Task? {
        return taskTagDao.getTaskWithTags(taskId)?.toDomain()
    }

    suspend fun saveTask(task: Task) {
        taskDao.upsertTask(task.toEntity())

        // Update tags
        val existingTags = taskTagDao.getTaskWithTags(task.id)?.tags?.map { it.id } ?: emptyList()
        val newTagIds = task.tags.map { it.id }

        // Remove old tags
        (existingTags - newTagIds.toSet()).forEach { tagId ->
            taskTagDao.removeTagFromTask(task.id, tagId)
        }

        // Add new tags
        (newTagIds - existingTags.toSet()).forEach { tagId ->
            taskTagDao.addTagToTask(task.id, tagId)
        }
    }
}

Conclusion

Room provides a robust foundation for local data persistence on Android. The key to success lies in thoughtful entity design, appropriate indexing, proper migration handling, and comprehensive testing.

Start with simple entities and add complexity as needed. Use projections to fetch only required data. Leverage Kotlin Flow for reactive updates. Test migrations thoroughly before releasing.

These patterns have served us well across dozens of production apps handling millions of records. Apply them thoughtfully to your specific use case, and Room will reliably serve your data persistence needs.


Building an Android app that needs robust local storage? We have implemented Room databases for apps serving millions of Australian users. Contact us to discuss your data architecture.