Room Database in Android 2026: Migraties, Relaties en Coroutines

Beheers Room database in Android met migraties, entity-relaties, Kotlin coroutines en Flow. Complete tutorial met productie-klare patterns voor 2026.

Room Database Android Kotlin SQLite data persistentie

Room database biedt een robuuste abstractielaag boven SQLite in Android en handelt de boilerplate code af die directe SQLite-gebruik omslachtig maakt. Met Room 2.6 en Kotlin coroutines is het bouwen van reactieve, type-veilige databaselagen aanzienlijk eenvoudiger geworden.

Room 2.6 Belangrijkste Features

Room 2.6 (stabiel in 2026) introduceert verbeterde migratie-ondersteuning, snellere KSP-verwerking en betere Flow-integratie. Deze tutorial gebruikt deze nieuwste APIs voor productie-klare code.

Room Database opzetten met Kotlin KSP

Room vereist drie kerncomponenten: entities (tabellen), DAOs (data access objects) en de databaseklasse. De KSP-annotationprocessor genereert implementatiecode tijdens het compileren.

De dependencies worden toegevoegd aan het module-niveau build.gradle.kts bestand. KSP heeft KAPT vervangen als aanbevolen annotationprocessor sinds 2024, met snellere buildtijden.

build.gradle.kts (Module :app)kotlin
plugins {
    id("com.google.devtools.ksp") version "2.1.0-1.0.29"
}

dependencies {
    val roomVersion = "2.6.1"
    implementation("androidx.room:room-runtime:$roomVersion")
    implementation("androidx.room:room-ktx:$roomVersion") // Coroutines support
    ksp("androidx.room:room-compiler:$roomVersion")
}

Het room-ktx artifact biedt coroutine-extensies, waardoor suspend-functies in DAOs mogelijk zijn.

Entities definiëren met Primary Keys en Indexen

Entities worden direct gemapped naar SQLite-tabellen. Elke entity-klasse heeft minimaal één primary key nodig. Het toevoegen van indexen op veelgebruikte kolommen verbetert de leesprestaties ten koste van iets langzamere schrijfoperaties.

User.ktkotlin
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey

@Entity(
    tableName = "users",
    indices = [
        Index(value = ["email"], unique = true), // Unique constraint + index
        Index(value = ["created_at"]) // Index for sorting queries
    ]
)
data class User(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val email: String,
    val displayName: String,
    @ColumnInfo(name = "created_at")
    val createdAt: Long = System.currentTimeMillis(),
    val isActive: Boolean = true
)

De @ColumnInfo-annotatie past de kolomnaam aan wanneer de Kotlin-propertynaam verschilt van de gewenste database-kolomnaam.

DAOs bouwen met Coroutines en Flow

DAOs definiëren de database-operaties. Room 2.6 ondersteunt drie asynchrone patterns: suspend-functies voor eenmalige operaties, Flow voor reactieve streams en ListenableFuture voor Java-interoperabiliteit.

UserDao.ktkotlin
import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Dao
interface UserDao {
    // One-shot insert - returns the generated ID
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: User): Long

    // Batch insert - efficient for multiple records
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insertUsers(users: List<User>)

    // Flow emits new data whenever the table changes
    @Query("SELECT * FROM users WHERE is_active = 1 ORDER BY created_at DESC")
    fun observeActiveUsers(): Flow<List<User>>

    // Suspend function for one-shot read
    @Query("SELECT * FROM users WHERE id = :userId")
    suspend fun getUserById(userId: Long): User?

    // Update returns the number of affected rows
    @Update
    suspend fun updateUser(user: User): Int

    // Delete by primary key
    @Delete
    suspend fun deleteUser(user: User)

    // Custom delete query
    @Query("DELETE FROM users WHERE is_active = 0")
    suspend fun deleteInactiveUsers(): Int
}

Flow-retournerende queries geven automatisch bijgewerkte resultaten uit wanneer de onderliggende data verandert. Dit elimineert handmatige refresh-logica in ViewModels.

De Room Database-klasse maken

De databaseklasse koppelt entities en DAOs aan elkaar. Een singleton-pattern voorkomt meerdere database-instanties, wat resource-lekken zou veroorzaken.

AppDatabase.ktkotlin
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(
    entities = [User::class, Post::class],
    version = 1,
    exportSchema = true // Generates schema JSON for migration validation
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    abstract fun postDao(): PostDao

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

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app_database"
                )
                    .fallbackToDestructiveMigration() // Only for development
                    .build()
                INSTANCE = instance
                instance
            }
        }
    }
}

Het instellen van exportSchema = true genereert JSON-schemabestanden in de schemas/ map, nuttig voor het valideren van migraties in tests.

Productie-migratiestrategie

Gebruik nooit fallbackToDestructiveMigration() in productie. Dit verwijdert alle gebruikersdata wanneer het schema wijzigt. De volgende sectie behandelt correcte migratie-handling.

Veilige database-migraties schrijven

Migraties behouden gebruikersdata wanneer het schema verandert tussen app-versies. Room valideert migraties tijdens runtime door het verwachte schema te vergelijken met de gemigreerde database.

Een migratie definieert de SQL-statements die nodig zijn om de database te transformeren van versie N naar versie N+1.

Migrations.ktkotlin
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

// Migration from version 1 to 2: add phone_number column
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE users ADD COLUMN phone_number TEXT")
    }
}

// Migration from version 2 to 3: add posts table with foreign key
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("""
            CREATE TABLE IF NOT EXISTS posts (
                id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
                user_id INTEGER NOT NULL,
                title TEXT NOT NULL,
                content TEXT NOT NULL,
                created_at INTEGER NOT NULL,
                FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
            )
        """.trimIndent())
        db.execSQL("CREATE INDEX index_posts_user_id ON posts(user_id)")
    }
}

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

Room ondersteunt migratie-chaining. Als een gebruiker upgradet van versie 1 naar versie 3, voert Room MIGRATION_1_2 en daarna MIGRATION_2_3 sequentieel uit.

Auto-migraties in Room 2.6

Voor eenvoudige schema-wijzigingen (kolommen, tabellen of indexen toevoegen) kan Room migraties automatisch genereren. Dit vermindert boilerplate-code voor straightforward updates.

AppDatabase.ktkotlin
@Database(
    entities = [User::class, Post::class],
    version = 3,
    autoMigrations = [
        AutoMigration(from = 1, to = 2), // Adding nullable column
        AutoMigration(
            from = 2,
            to = 3,
            spec = AutoMigration2To3::class // Complex changes need spec
        )
    ],
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    // DAOs...
}

// Migration spec for changes that need hints
@RenameColumn(tableName = "users", fromColumnName = "name", toColumnName = "display_name")
class AutoMigration2To3 : AutoMigrationSpec

Auto-migraties vereisen exportSchema = true om versies te vergelijken. Voor kolom-hernoemingen, tabel-hernoemingen of verwijderingen moet een AutoMigrationSpec met de juiste annotaties worden opgegeven.

Klaar om je Android gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Entity-relaties modelleren met Embedded Objects

Room ondersteunt drie relatiepatronen: embedded objecten, one-to-many en many-to-many. In tegenstelling tot ORMs retourneert Room platte data — relaties vereisen expliciete queries.

One-to-Many: Gebruiker met Posts

Een gebruiker kan meerdere posts hebben. De relatie wordt gedefinieerd met de @Relation-annotatie in een data class die beide entities combineert.

Post.ktkotlin
@Entity(
    tableName = "posts",
    foreignKeys = [
        ForeignKey(
            entity = User::class,
            parentColumns = ["id"],
            childColumns = ["user_id"],
            onDelete = ForeignKey.CASCADE // Delete posts when user deleted
        )
    ],
    indices = [Index("user_id")] // Required for foreign key performance
)
data class Post(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    @ColumnInfo(name = "user_id") val userId: Long,
    val title: String,
    val content: String,
    @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
)

// UserWithPosts.kt - Relation container
data class UserWithPosts(
    @Embedded val user: User,
    @Relation(
        parentColumn = "id",
        entityColumn = "user_id"
    )
    val posts: List<Post>
)

// In UserDao.kt
@Transaction // Ensures atomic read of user + posts
@Query("SELECT * FROM users WHERE id = :userId")
suspend fun getUserWithPosts(userId: Long): UserWithPosts?

@Transaction
@Query("SELECT * FROM users WHERE is_active = 1")
fun observeActiveUsersWithPosts(): Flow<List<UserWithPosts>>

De @Transaction-annotatie voorkomt inconsistente reads wanneer de relatie intern meerdere queries omvat.

Many-to-Many: Gebruikers en Tags

Many-to-many relaties vereisen een junction-tabel (cross-reference) die paren van foreign keys opslaat.

Tag.ktkotlin
@Entity(tableName = "tags")
data class Tag(
    @PrimaryKey(autoGenerate = true) val tagId: Long = 0,
    val name: String
)

// UserTagCrossRef.kt - Junction table
@Entity(
    tableName = "user_tag_cross_ref",
    primaryKeys = ["userId", "tagId"],
    foreignKeys = [
        ForeignKey(entity = User::class, parentColumns = ["id"], childColumns = ["userId"], onDelete = ForeignKey.CASCADE),
        ForeignKey(entity = Tag::class, parentColumns = ["tagId"], childColumns = ["tagId"], onDelete = ForeignKey.CASCADE)
    ]
)
data class UserTagCrossRef(
    val userId: Long,
    val tagId: Long
)

// UserWithTags.kt
data class UserWithTags(
    @Embedded val user: User,
    @Relation(
        parentColumn = "id",
        entityColumn = "tagId",
        associateBy = Junction(UserTagCrossRef::class)
    )
    val tags: List<Tag>
)

De associateBy-parameter specificeert de junction-tabel die gebruikers aan tags koppelt.

Room integreren met ViewModel en Repository

Een schone architectuur scheidt de databaselaag van de UI. Het repository-pattern abstraheert databronnen, terwijl ViewModel de state blootstelt aan Compose UI.

UserRepository.ktkotlin
class UserRepository(private val userDao: UserDao) {

    val activeUsers: Flow<List<User>> = userDao.observeActiveUsers()

    suspend fun createUser(email: String, displayName: String): Long {
        val user = User(email = email, displayName = displayName)
        return userDao.insertUser(user)
    }

    suspend fun deactivateUser(userId: Long) {
        userDao.getUserById(userId)?.let { user ->
            userDao.updateUser(user.copy(isActive = false))
        }
    }
}

// UserViewModel.kt
class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    val users: StateFlow<List<User>> = repository.activeUsers
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    fun createUser(email: String, name: String) {
        viewModelScope.launch {
            repository.createUser(email, name)
            // No manual refresh needed - Flow emits automatically
        }
    }
}

De stateIn-operator converteert de koude Flow van Room naar een warme StateFlow geschikt voor Compose-collectie. Voor meer details over coroutine-patterns, zie de gids over Kotlin Coroutines.

Room Databases testen

Room biedt een in-memory database builder voor testen: Room.inMemoryDatabaseBuilder(). Tests draaien sneller en persisteren geen data tussen runs. Test migraties altijd met MigrationTestHelper uit het room-testing artifact.

Type Converters voor complexe types

Room slaat alleen primitieve types native op. Type converters transformeren complexe types (datums, enums, lijsten) naar opslagbare formaten.

Converters.ktkotlin
import androidx.room.TypeConverter
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset

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

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

    // LocalDate <-> String (ISO format)
    @TypeConverter
    fun fromLocalDate(date: LocalDate?): String? = date?.toString()

    @TypeConverter
    fun toLocalDate(dateStr: String?): LocalDate? = dateStr?.let { LocalDate.parse(it) }

    // List<String> <-> JSON string
    @TypeConverter
    fun fromStringList(list: List<String>?): String? =
        list?.joinToString(separator = ",")

    @TypeConverter
    fun toStringList(data: String?): List<String>? =
        data?.split(",")?.filter { it.isNotBlank() }
}

// Register in database class
@Database(...)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase()

Voor JSON-serialisatie van complexe objecten wordt het gebruik van kotlinx.serialization aanbevolen in plaats van handmatige string-manipulatie.

Database-operaties afhandelen in Coroutines Scope

Room DAO-operaties moeten worden uitgevoerd op de IO-dispatcher. Hoewel Room threading intern afhandelt voor suspend-functies, houdt error-handling op repository-niveau de code robuust.

UserRepository.kt with error handlingkotlin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class UserRepository(private val userDao: UserDao) {

    suspend fun createUser(email: String, displayName: String): Result<Long> {
        return withContext(Dispatchers.IO) {
            try {
                val id = userDao.insertUser(
                    User(email = email, displayName = displayName)
                )
                Result.success(id)
            } catch (e: SQLiteConstraintException) {
                // Unique constraint violation (duplicate email)
                Result.failure(DuplicateEmailException(email))
            }
        }
    }

    suspend fun deleteAllInactive(): Int = withContext(Dispatchers.IO) {
        userDao.deleteInactiveUsers()
    }
}

class DuplicateEmailException(email: String) : Exception("Email already exists: $email")

Resultaten wrappen in Result<T> stelt ViewModels in staat om fouten elegant af te handelen zonder try-catch boilerplate.

Conclusie

Het bouwen van een productie-klare Room-databaselaag omvat verschillende belangrijke praktijken:

  • Gebruik KSP in plaats van KAPT voor snellere compilatie met Room 2.6
  • Voeg indexen toe op kolommen die worden gebruikt in WHERE-clausules en JOIN-condities
  • Retourneer Flow vanuit DAOs voor automatische UI-updates wanneer data verandert
  • Schrijf expliciete migraties voor schema-wijzigingen — gebruik nooit destructieve migratie in productie
  • Gebruik @Transaction voor relatie-queries om consistente reads te garanderen
  • Wrap database-operaties in Result voor schone error-handling in ViewModels
  • Test migraties met MigrationTestHelper voordat updates worden uitgebracht

Deze patterns kunnen worden verdiept met Room database sollicitatievragen.

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Tags

#android
#room
#kotlin
#sqlite
#coroutines
#database

Delen

Gerelateerde artikelen