Room (Android Architecture Components)
Room (Android Room Persistence Library) is an Android-exclusive ORM (Object-Relational Mapping) library provided as part of Google's Android Jetpack project that "provides an abstraction layer over SQLite to allow for more robust database access." While leveraging the full power of SQLite, Room offers compile-time SQL query validation, type-safe database operations, and integration with LiveData and Kotlin Coroutines, providing a powerful data persistence solution for modern Android app development. Designed with a Kotlin First approach, it achieves natural integration with Android's MVVM architecture.
Library
Room (Android Architecture Components)
Overview
Room (Android Room Persistence Library) is an Android-exclusive ORM (Object-Relational Mapping) library provided as part of Google's Android Jetpack project that "provides an abstraction layer over SQLite to allow for more robust database access." While leveraging the full power of SQLite, Room offers compile-time SQL query validation, type-safe database operations, and integration with LiveData and Kotlin Coroutines, providing a powerful data persistence solution for modern Android app development. Designed with a Kotlin First approach, it achieves natural integration with Android's MVVM architecture.
Details
Room 2025 edition embodies the latest best practices in Android development through Kotlin Multiplatform support, Kotlin Symbol Processing (KSP) support, and complete Kotlin Coroutines integration. Through a clear architectural design with three main components - Entity, DAO, and Database - it provides complex database schema management and automatic migration functionality. Natural integration with LiveData, RxJava, and Kotlin Flow makes reactive programming and data binding easy to implement. Additionally, with related entity management, Full-Text Search (FTS) support, and encryption capabilities, it strongly supports enterprise-level Android application development.
Key Features
- Compile-time Validation: Prevents runtime errors through compile-time SQL query validation
- Type Safety: Completely type-safe database operations leveraging Kotlin's type system
- Jetpack Integration: Complete integration with LiveData, ViewModel, and Data Binding
- Automatic Migration: Automatic database schema change management
- Kotlin Coroutines: Full support for asynchronous database operations
- Reactive Queries: Automatic UI updates when data changes
Pros and Cons
Pros
- Hides SQLite complexity while providing access to all features
- Significantly improves app stability through compile-time query validation
- Achieves automatic UI updates through LiveData integration
- Efficient asynchronous processing with Kotlin Coroutines support
- Natural MVVM architecture implementation through complete Android Jetpack integration
- Long-term support by Google and active community
Cons
- Android-exclusive, not usable for cross-platform development
- Limited to SQLite, cannot use other database engines
- Potential initialization time increase with large datasets
- Complex queries may require relying on raw SQL
- Flexible schema design like NoSQL is restricted
- Risk of production environment issues due to poor migration design
Reference Pages
- Room Official Documentation (Android Developers)
- Android Room | Jetpack Library
- Room GitHub Repository
Code Examples
Setup
// app/build.gradle (Module: app)
android {
compileSdk 34
defaultConfig {
minSdk 21
targetSdk 34
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs += ["-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"]
}
}
dependencies {
def room_version = "2.6.1"
// Room core
implementation "androidx.room:room-runtime:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// KSP (recommended - faster than kapt)
ksp "androidx.room:room-compiler:$room_version"
// Asynchronous processing and reactive programming
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0"
// Testing
testImplementation "androidx.room:room-testing:$room_version"
androidTestImplementation "androidx.arch.core:core-testing:2.2.0"
}
// Add KSP plugin
plugins {
id 'com.google.devtools.ksp' version '1.9.22-1.0.17'
}
// Set backup rules in AndroidManifest.xml
<application
android:name=".MyApplication"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.MyApp">
<!-- Room database configuration -->
<meta-data
android:name="room.schemaLocation"
android:value="./schemas" />
</application>
Entity Definition
import androidx.room.*
import java.time.LocalDateTime
import java.time.OffsetDateTime
// Basic entity
@Entity(tableName = "users")
data class User(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "username")
val username: String,
@ColumnInfo(name = "email")
val email: String,
@ColumnInfo(name = "created_at", defaultValue = "CURRENT_TIMESTAMP")
val createdAt: OffsetDateTime = OffsetDateTime.now(),
@ColumnInfo(name = "is_active", defaultValue = "1")
val isActive: Boolean = true
)
// Composite primary key and foreign key
@Entity(
tableName = "posts",
foreignKeys = [
ForeignKey(
entity = User::class,
parentColumns = ["id"],
childColumns = ["user_id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
)
],
indices = [
Index(value = ["user_id"]),
Index(value = ["title"], unique = false),
Index(value = ["created_at"])
]
)
data class Post(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "user_id")
val userId: Long,
@ColumnInfo(name = "title")
val title: String,
@ColumnInfo(name = "content")
val content: String,
@ColumnInfo(name = "created_at")
val createdAt: OffsetDateTime = OffsetDateTime.now(),
@ColumnInfo(name = "updated_at")
val updatedAt: OffsetDateTime? = null
)
// Embedded object
data class Address(
val street: String,
val city: String,
val country: String,
val postalCode: String
)
@Entity(tableName = "user_profiles")
data class UserProfile(
@PrimaryKey val userId: Long,
@ColumnInfo(name = "full_name")
val fullName: String,
@Embedded
val address: Address,
@ColumnInfo(name = "phone_number")
val phoneNumber: String?
)
DAO Implementation
import androidx.room.*
import androidx.lifecycle.LiveData
import kotlinx.coroutines.flow.Flow
@Dao
interface UserDao {
// Basic CRUD operations
@Query("SELECT * FROM users ORDER BY created_at DESC")
fun getAllUsers(): Flow<List<User>>
@Query("SELECT * FROM users WHERE id = :userId")
suspend fun getUserById(userId: Long): User?
@Query("SELECT * FROM users WHERE username = :username")
suspend fun getUserByUsername(username: String): User?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: User): Long
@Insert
suspend fun insertUsers(users: List<User>): List<Long>
@Update
suspend fun updateUser(user: User)
@Delete
suspend fun deleteUser(user: User)
@Query("DELETE FROM users WHERE id = :userId")
suspend fun deleteUserById(userId: Long)
// Complex queries
@Query("""
SELECT * FROM users
WHERE is_active = 1
AND created_at >= :fromDate
ORDER BY username ASC
""")
fun getActiveUsersFrom(fromDate: OffsetDateTime): Flow<List<User>>
@Query("""
SELECT u.*, COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.id
HAVING post_count > :minPosts
ORDER BY post_count DESC
""")
suspend fun getUsersWithMinimumPosts(minPosts: Int): List<UserWithPostCount>
// Aggregation operations
@Query("SELECT COUNT(*) FROM users WHERE is_active = 1")
suspend fun getActiveUserCount(): Int
@Query("SELECT username FROM users WHERE created_at >= :date")
suspend fun getRecentUsernames(date: OffsetDateTime): List<String>
}
@Dao
interface PostDao {
@Query("SELECT * FROM posts WHERE user_id = :userId ORDER BY created_at DESC")
fun getPostsByUserId(userId: Long): Flow<List<Post>>
@Query("""
SELECT p.*, u.username
FROM posts p
INNER JOIN users u ON p.user_id = u.id
WHERE p.title LIKE '%' || :searchTerm || '%'
OR p.content LIKE '%' || :searchTerm || '%'
ORDER BY p.created_at DESC
""")
suspend fun searchPosts(searchTerm: String): List<PostWithUser>
@Insert
suspend fun insertPost(post: Post): Long
@Update
suspend fun updatePost(post: Post)
@Transaction
suspend fun updatePostWithTimestamp(post: Post) {
val updatedPost = post.copy(updatedAt = OffsetDateTime.now())
updatePost(updatedPost)
}
}
// Related entities
data class UserWithPostCount(
@Embedded val user: User,
@ColumnInfo(name = "post_count") val postCount: Int
)
data class PostWithUser(
@Embedded val post: Post,
@ColumnInfo(name = "username") val username: String
)
data class UserWithPosts(
@Embedded val user: User,
@Relation(
parentColumn = "id",
entityColumn = "user_id"
)
val posts: List<Post>
)
Database Class
import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [User::class, Post::class, UserProfile::class],
version = 3,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(
from = 2,
to = 3,
spec = AppDatabase.Migration2to3::class
)
]
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun postDao(): PostDao
@DeleteColumn(tableName = "users", columnName = "temp_column")
@RenameColumn(tableName = "posts", fromColumnName = "old_title", toColumnName = "title")
class Migration2to3 : AutoMigrationSpec
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
)
.addMigrations(MIGRATION_1_2)
.addCallback(DatabaseCallback())
.fallbackToDestructiveMigration() // Development only
.build()
INSTANCE = instance
instance
}
}
// Manual migration example
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("""
ALTER TABLE users
ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1
""")
database.execSQL("""
CREATE INDEX index_users_is_active
ON users(is_active)
""")
}
}
}
private class DatabaseCallback : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
// Initial data insertion processing
}
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
// Database open processing
}
}
}
// Type converters
class Converters {
@TypeConverter
fun fromOffsetDateTime(value: OffsetDateTime?): Long? {
return value?.toInstant()?.toEpochMilli()
}
@TypeConverter
fun toOffsetDateTime(value: Long?): OffsetDateTime? {
return value?.let {
OffsetDateTime.ofInstant(
java.time.Instant.ofEpochMilli(it),
java.time.ZoneOffset.UTC
)
}
}
@TypeConverter
fun fromStringList(value: List<String>): String {
return value.joinToString(",")
}
@TypeConverter
fun toStringList(value: String): List<String> {
return if (value.isBlank()) emptyList() else value.split(",")
}
}
ViewModel and Repository
import androidx.lifecycle.*
import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
// Repository
class UserRepository @Inject constructor(
private val userDao: UserDao,
private val postDao: PostDao
) {
fun getAllUsers(): Flow<List<User>> = userDao.getAllUsers()
suspend fun getUserById(userId: Long): User? = userDao.getUserById(userId)
suspend fun insertUser(user: User): Long = userDao.insertUser(user)
suspend fun updateUser(user: User) = userDao.updateUser(user)
suspend fun deleteUser(user: User) = userDao.deleteUser(user)
fun getUsersWithPosts(): Flow<List<UserWithPosts>> {
return userDao.getAllUsers().map { users ->
users.map { user ->
val posts = postDao.getPostsByUserId(user.id).first()
UserWithPosts(user, posts)
}
}
}
suspend fun searchUsers(query: String): List<User> {
return userDao.getAllUsers().first().filter {
it.username.contains(query, ignoreCase = true) ||
it.email.contains(query, ignoreCase = true)
}
}
}
// ViewModel
class UserViewModel(private val repository: UserRepository) : ViewModel() {
private val _searchQuery = MutableLiveData("")
val searchQuery: LiveData<String> = _searchQuery
private val _selectedUser = MutableLiveData<User?>()
val selectedUser: LiveData<User?> = _selectedUser
// Reactive user list
val users: LiveData<List<User>> = repository.getAllUsers().asLiveData()
// Search results
val searchResults: LiveData<List<User>> = _searchQuery.switchMap { query ->
if (query.isBlank()) {
repository.getAllUsers().asLiveData()
} else {
liveData {
emit(repository.searchUsers(query))
}
}
}
// Active user count
val activeUserCount: LiveData<Int> = users.map { userList ->
userList.count { it.isActive }
}
fun searchUsers(query: String) {
_searchQuery.value = query
}
fun selectUser(user: User) {
_selectedUser.value = user
}
fun createUser(username: String, email: String) {
viewModelScope.launch {
try {
val newUser = User(
username = username,
email = email,
createdAt = OffsetDateTime.now()
)
repository.insertUser(newUser)
} catch (e: Exception) {
// Error handling
_errorMessage.value = "Failed to create user: ${e.message}"
}
}
}
fun updateUser(user: User) {
viewModelScope.launch {
repository.updateUser(user)
}
}
fun deleteUser(user: User) {
viewModelScope.launch {
repository.deleteUser(user)
}
}
private val _errorMessage = MutableLiveData<String>()
val errorMessage: LiveData<String> = _errorMessage
}
Transaction Processing
@Dao
interface TransactionDao {
@Transaction
suspend fun insertUserWithProfile(user: User, profile: UserProfile) {
val userId = insertUser(user)
val updatedProfile = profile.copy(userId = userId)
insertProfile(updatedProfile)
}
@Transaction
suspend fun transferPosts(fromUserId: Long, toUserId: Long) {
val posts = getPostsByUserId(fromUserId)
posts.forEach { post ->
val transferredPost = post.copy(
userId = toUserId,
updatedAt = OffsetDateTime.now()
)
updatePost(transferredPost)
}
}
@Insert
suspend fun insertUser(user: User): Long
@Insert
suspend fun insertProfile(profile: UserProfile)
@Query("SELECT * FROM posts WHERE user_id = :userId")
suspend fun getPostsByUserId(userId: Long): List<Post>
@Update
suspend fun updatePost(post: Post)
}
// Transaction usage in Repository
class UserService @Inject constructor(
private val database: AppDatabase,
private val userDao: UserDao,
private val transactionDao: TransactionDao
) {
suspend fun createUserWithProfile(
username: String,
email: String,
fullName: String,
address: Address
): Result<Long> {
return try {
database.withTransaction {
val user = User(username = username, email = email)
val userId = userDao.insertUser(user)
val profile = UserProfile(
userId = userId,
fullName = fullName,
address = address,
phoneNumber = null
)
transactionDao.insertProfile(profile)
userId
}
Result.success(userId)
} catch (e: Exception) {
Result.failure(e)
}
}
}
Test Implementation
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.*
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class UserDaoTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
private lateinit var database: AppDatabase
private lateinit var userDao: UserDao
@Before
fun setup() {
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
)
.allowMainThreadQueries()
.build()
userDao = database.userDao()
}
@After
fun teardown() {
database.close()
}
@Test
fun insertAndGetUser() = runTest {
// Given
val user = User(
username = "testuser",
email = "[email protected]",
createdAt = OffsetDateTime.now()
)
// When
val userId = userDao.insertUser(user)
val retrievedUser = userDao.getUserById(userId)
// Then
Assert.assertNotNull(retrievedUser)
Assert.assertEquals(user.username, retrievedUser?.username)
Assert.assertEquals(user.email, retrievedUser?.email)
}
@Test
fun getAllUsers_returnsAllUsers() = runTest {
// Given
val users = listOf(
User(username = "user1", email = "[email protected]"),
User(username = "user2", email = "[email protected]"),
User(username = "user3", email = "[email protected]")
)
// When
userDao.insertUsers(users)
val allUsers = userDao.getAllUsers().first()
// Then
Assert.assertEquals(3, allUsers.size)
Assert.assertTrue(allUsers.any { it.username == "user1" })
Assert.assertTrue(allUsers.any { it.username == "user2" })
Assert.assertTrue(allUsers.any { it.username == "user3" })
}
@Test
fun updateUser_updatesCorrectly() = runTest {
// Given
val user = User(username = "original", email = "[email protected]")
val userId = userDao.insertUser(user)
// When
val updatedUser = user.copy(id = userId, username = "updated")
userDao.updateUser(updatedUser)
val retrievedUser = userDao.getUserById(userId)
// Then
Assert.assertEquals("updated", retrievedUser?.username)
Assert.assertEquals("[email protected]", retrievedUser?.email)
}
@Test
fun deleteUser_removesUser() = runTest {
// Given
val user = User(username = "toDelete", email = "[email protected]")
val userId = userDao.insertUser(user)
// When
userDao.deleteUserById(userId)
val deletedUser = userDao.getUserById(userId)
// Then
Assert.assertNull(deletedUser)
}
@Test
fun searchQuery_worksCorrectly() = runTest {
// Given
val now = OffsetDateTime.now()
val pastDate = now.minusDays(30)
val oldUser = User(username = "old", email = "[email protected]", createdAt = pastDate)
val newUser = User(username = "new", email = "[email protected]", createdAt = now)
userDao.insertUser(oldUser)
userDao.insertUser(newUser)
// When
val recentUsers = userDao.getActiveUsersFrom(now.minusDays(7)).first()
// Then
Assert.assertEquals(1, recentUsers.size)
Assert.assertEquals("new", recentUsers[0].username)
}
}