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
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<String>, 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.