Room 3.0 is not just an incremental update; it’s a foundational rewrite of the Android persistence library. The primary goal of this release is to support Kotlin Multiplatform (KMP), allowing Room to run on Android, iOS, JVM, and Native targets. To achieve this, the library has been modernized with a new package structure, a shift to Kotlin Symbol Processing (KSP), and a fully coroutine-first API.
This article highlights the key changes and new APIs introduced in Room 3.0, comparing them to the familiar Room 2.x patterns.
The Big Shift: Kotlin Multiplatform & Package Changes
To support KMP without breaking existing Android apps, Room 3.0 moves to a new package namespace: androidx.room3.
- Room 2.x: androidx.room.*
- Room 3.0: androidx.room3.*
This allows you to migrate incrementally. You can have both Room 2.x and Room 3.0 in the same project (though they manage separate databases).
KSP is Now Mandatory
Room 3.0 drops support for KAPT (Kotlin Annotation Processing Tool) entirely. You must use KSP (Kotlin Symbol Processing) for code generation.
// build.gradle.kts
dependencies {
implementation("androidx.room3:room3-runtime:3.0.0-alpha01")
ksp("androidx.room3:room3-compiler:3.0.0-alpha01")
// Optional: Bundled SQLite driver for consistent behavior across platforms
implementation("androidx.sqlite:sqlite-bundled:2.5.0-alpha01")
}
KMP-Ready Instantiation: @ConstructedBy
One of the biggest “modernization” changes is how the database is instantiated. On Android (Room 2.x), Room used reflection (Class.newInstance) to create the generated database implementation. On platforms like iOS (Native), reflection is limited or unavailable.
Room 3.0 introduces the @ConstructedBy annotation to link your abstract database class to its generated implementation constructor at compile time.
Room 2.x
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase()
Room 3.0
import androidx.room3.ConstructedBy
import androidx.room3.Database
import androidx.room3.RoomDatabaseConstructor
@Database(entities = [User::class], version = 1)
@ConstructedBy(AppDatabaseConstructor::class) // Links to generated constructor
abstract class AppDatabase : RoomDatabase()
// This interface is generated by KSP
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase>
SQLite Driver Abstraction
Room 3.0 decouples the library from the Android framework’s SQLiteOpenHelper. It now uses a generic SQLiteDriver interface.
This allows you to plug in different drivers. For KMP, the Bundled SQLite Driver is recommended. It compiles a specific version of SQLite directly into your app, ensuring that your database behaves exactly the same on iOS 17, Android 14, and Windows.
// Common Main
fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
val dbFile = getDatabasePath()
return Room.databaseBuilder<AppDatabase>(name = dbFile.absolutePath)
.setDriver(BundledSQLiteDriver()) // Use the bundled driver
.setQueryCoroutineContext(Dispatchers.IO)
}
API Modernization: Coroutines First
Room 3.0 embraces Kotlin coroutines at a deeper level. While Room 2.x added suspend support on top of Java-based internals, Room 3.0’s core is built around suspending functions and Flows.
Transactions
In Room 2.x, transactions were often thread-blocking. Room 3.0 introduces suspending transaction scopes that are safer and more idiomatic.
Room 2.x (Blocking)
// Blocks the current thread
roomDatabase.runInTransaction {
userDao.insert(user)
accountDao.update(account)
}
Room 3.0 (Suspending)
// Suspends the coroutine; non-blocking
roomDatabase.withWriteTransaction {
// Executes in an IMMEDIATE transaction scope
userDao.insert(user)
accountDao.update(account)
}
For read-only operations that need consistency, you can use withReadTransaction:
val userWithDetails = roomDatabase.withReadTransaction {
// Executes in a DEFERRED transaction scope
val user = userDao.getUser(userId)
val details = detailsDao.getDetails(userId)
UserWithDetails(user, details)
}
Connection Management
Room 3.0 exposes lower-level control over database connections, which is critical for managing concurrency in a multi-platform environment.
- useWriterConnection: Acquires the single exclusive write connection.
- useReaderConnection: Acquires one of the available read connections.
// Explicitly use the writer connection
roomDatabase.useWriterConnection { transactor ->
transactor.immediateTransaction {
// Perform complex write operations
}
}
Reactive Invalidation
The InvalidationTracker has been upgraded to support creating Flows directly. This allows you to build reactive pipelines that trigger whenever specific tables are modified.
Room 2.x You typically relied on DAOs returning LiveData or Flow.
Room 3.0 You can observe table changes programmatically:
fun getArtistTours(from: Date, to: Date): Flow<Map<Artist, TourState>> {
// Create a Flow that emits whenever the "Artist" table is modified
return db.invalidationTracker.createFlow("Artist").map { _ ->
val artists = artistsDao.getAllArtists()
val tours = tourService.fetchStates(artists.map { it.id })
associateTours(artists, tours, from, to)
}
}
Custom DAO Return Types
Room 3.0 introduces a powerful new capability: Custom DAO Return Types. In previous versions, you were limited to the return types Room explicitly supported (like Flow, LiveData, PagingSource, etc.). If you wanted to return a custom type, you often had to write wrapper code.
With Room 3.0, you can define your own DaoReturnTypeConverter to handle any return type you need.
How to Implement
Define the Converter: Create a class or object with a function annotated with @DaoReturnTypeConverter. This function receives the database, query details, and a continuation to execute the query.
object MyResultConverter {
@DaoReturnTypeConverter(operations = [OperationType.READ])
fun <T : Any> convert(
db: RoomDatabase,
tableNames: Array<String>,
query: RoomRawQuery,
execute: suspend (RoomRawQuery) -> T
): MyResult<T> {
// You can execute the query and wrap the result
return MyResult.Success(runBlocking { execute(query) })
}
}
Register the Converter: Add the @DaoReturnTypeConverters annotation to your Database or DAO class. You can apply it globally on the @Database or specifically on a @Dao interface.
@Database(entities = [Note::class], version = 1)
@DaoReturnTypeConverters(MyResultConverter::class)
abstract class AppDatabase : RoomDatabase() { ... }
// Or specifically on a DAO
@Dao
@DaoReturnTypeConverters(MyResultConverter::class)
interface NoteDao { ... }
Use in DAO: Now you can use your custom type directly in your DAO methods.
@Dao
interface NoteDao {
@Query("SELECT * FROM note")
fun getNotes(): MyResult<List<Note>>
}
This feature is particularly useful for integrating Room with custom reactive libraries, result types, or logging frameworks.
Predefined Custom Converters
Room 3.0 provides several built-in DaoReturnTypeConverter implementations for common libraries, which you must explicitly register if you want to use them.
For example, to return a PagingSource from your DAO, you need to register PagingSourceDaoReturnTypeConverter (from androidx.room3:room3-paging). Without it, KSP will fail with an error like: “[ksp] Not sure how to convert the query result to this function’s return type”.
Similarly, if you want to return RxJava2 or RxJava3 types (Flowable, Observable), you must register RxJava2DaoReturnTypeConverter or RxJava3DaoReturnTypeConverter respectively. These converters are not enabled by default; you must opt-in by adding the @DaoReturnTypeConverters annotation.
Example Usage:
@Dao
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
interface NoteDao {
// This now works because the converter is registered!
@Query("SELECT * FROM note")
fun getNotesPagingSource(): PagingSource<Int, Note>
}
Comparison: @TypeConverter vs @DaoReturnTypeConverter
It’s easy to confuse these two, but they solve completely different problems.
- @TypeConverter (Standard Room 2.x): Converts data values for individual columns. You use this when you need to store a type that SQLite doesn’t support, like saving a Date object as a Long timestamp or a UUID as a String.
- @DaoReturnTypeConverter (New in Room 3.0): Converts the method return type of a DAO function. You use this when you want your DAO to return a custom container, such as a specific reactive type (e.g., RxJava3 Observable) or a custom result wrapper (e.g., MyResult<List<Note>>).
In Room 3.0, you will likely use both:
- Use @TypeConverter to tell Room how to save your Date fields (just like in Room 2.x).
- Use @DaoReturnTypeConverter if you want your DAO to return MyCustomResponse<List<Note>> directly.
// Example: Using both in AppDatabase
@Database(entities = [Note::class], version = 1)
@TypeConverters(Converters::class) // Standard Room 2.x style converters for column data
@DaoReturnTypeConverters(MyResultConverter::class) // New Room 3.0 converters for return types
abstract class AppDatabase : RoomDatabase() { ... }
Conclusion
Room 3.0 is a massive step forward. By decoupling from the Android framework and embracing KMP, it ensures that your data layer logic can be shared across platforms. The new androidx.room3 package and KSP-first approach provide a clean slate for modernization, while the coroutine-native APIs for transactions and connections make the library safer and more powerful to use in modern Kotlin applications.
As the ecosystem moves towards Multiplatform, Room 3.0 will be the standard for local persistence.
Resources
- Room 3.0 – Modernizing the Room
- Room 3.0 | Jetpack | Android Developers
- GitHub – oguzhanaslann/Room3
Love you all.
Stay tune for upcoming blogs.
Take care.
Room 3.0 New Features and API Changes for Android Developers was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.



This Post Has 0 Comments