You define an @Entity, write a @Dao interface, annotate a @Database class, and Room magically gives you a working database. But what’s actually happening? How does Room turn your annotations into SQL? Where does the generated code live? How does Room know to re-emit a Flow when data changes? Understanding the internals makes you better at debugging, writing efficient queries, and answering interview questions. This guide opens up Room’s hood.
The Mental Model — What Room Actually Is
// Room is NOT a database — it's a LAYER on top of SQLite
//
// YOUR CODE ROOM (generated) SQLITE
// (annotations) (real implementation) (actual database)
//
// @Entity Article → CREATE TABLE articles ... → articles.db file
// @Dao ArticleDao → ArticleDao_Impl.java → SQL queries
// @Database AppDatabase → AppDatabase_Impl.java → SQLiteOpenHelper
//
// You write: interfaces and data classes with annotations
// Room generates: full Java/Kotlin implementation classes
// SQLite does: the actual disk I/O and query execution
//
// The generation happens at COMPILE TIME via KSP (or previously KAPT)
// The generated code is plain, readable Java — no reflection, no magic
// You can read it yourself in: build/generated/ksp/debug/java/...
What Room Generates — The Real Code
For @Database — AppDatabase_Impl
// YOUR CODE:
@Database(entities = [ArticleEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun articleDao(): ArticleDao
}
// ROOM GENERATES: AppDatabase_Impl (simplified)
class AppDatabase_Impl extends AppDatabase {
// This is a REAL CLASS generated by Room's KSP processor
// You can find it in: build/generated/ksp/debug/java/.../AppDatabase_Impl.java
private volatile ArticleDao_Impl _articleDao;
@Override
protected SupportSQLiteOpenHelper createOpenHelper(DatabaseConfiguration config) {
// SupportSQLiteOpenHelper is an ABSTRACT CLASS from androidx.sqlite
// It wraps Android's SQLiteOpenHelper with a cleaner API
// Room uses this to create/open/migrate the database file
return config.sqliteOpenHelperFactory.create(
SupportSQLiteOpenHelper.Configuration.builder(config.context)
.name(config.name) // database file name
.callback(new RoomOpenHelper(
config,
new RoomOpenHelper.Delegate(1) {
// version = 1 (from @Database annotation)
@Override
public void createAllTables(SupportSQLiteDatabase db) {
// Called when database is FIRST CREATED
// Room generates ALL the CREATE TABLE statements:
db.execSQL("CREATE TABLE IF NOT EXISTS `articles` ("
+ "`id` TEXT NOT NULL, "
+ "`title` TEXT NOT NULL, "
+ "`content` TEXT NOT NULL, "
+ "`published_at` INTEGER NOT NULL, "
+ "PRIMARY KEY(`id`))");
// Room also creates the room_master_table:
db.execSQL("CREATE TABLE IF NOT EXISTS `room_master_table` ("
+ "`id` INTEGER PRIMARY KEY, "
+ "`identity_hash` TEXT)");
db.execSQL("INSERT OR REPLACE INTO room_master_table (id,identity_hash)"
+ " VALUES(42, 'abc123def456...')");
// identity_hash is a HASH of your schema
// Room checks this on open — if it doesn't match,
// the schema changed without a migration → crash or destructive fallback
}
}
))
.build()
);
}
@Override
public ArticleDao articleDao() {
if (_articleDao != null) return _articleDao;
synchronized(this) {
if (_articleDao == null) {
_articleDao = new ArticleDao_Impl(this);
// Creates the DAO implementation ONCE (lazy singleton)
}
return _articleDao;
}
}
}
// KEY INSIGHT: Room generates a REAL class that extends your abstract class
// No reflection, no proxy — just generated Java code compiled into your APK
For @Dao — ArticleDao_Impl
// YOUR CODE:
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY published_at DESC")
fun getAllArticles(): Flow<List<ArticleEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertArticles(articles: List<ArticleEntity>)
@Query("SELECT * FROM articles WHERE id = :articleId")
suspend fun getArticleById(articleId: String): ArticleEntity?
}
// ROOM GENERATES: ArticleDao_Impl (simplified)
class ArticleDao_Impl implements ArticleDao {
private final RoomDatabase __db;
// Reference to the database — used for getting the SupportSQLiteDatabase
ArticleDao_Impl(RoomDatabase __db) {
this.__db = __db;
}
// ═══ @Query returning Flow ═══════════════════════════════════════
@Override
public Flow<List<ArticleEntity>> getAllArticles() {
final String _sql = "SELECT * FROM articles ORDER BY published_at DESC";
// Room extracts the SQL from @Query annotation at compile time
// SQL is VALIDATED at compile time — typo in table name? → compile error!
return CoroutinesRoom.createFlow(
__db,
false, // inTransaction
new String[]{"articles"}, // TABLES TO OBSERVE ← this is the magic!
new Callable<List<ArticleEntity>>() {
@Override
public List<ArticleEntity> call() {
// This Callable runs the ACTUAL SQL query
final Cursor _cursor = DBUtil.query(__db, _sql, false, null);
// DBUtil.query() is an INTERNAL FUNCTION from Room
// Executes the SQL and returns a Cursor
try {
final List<ArticleEntity> _result = new ArrayList<>();
while (_cursor.moveToNext()) {
final ArticleEntity _item = new ArticleEntity(
_cursor.getString(0), // id
_cursor.getString(1), // title
_cursor.getString(2), // content
_cursor.getLong(3) // published_at
);
// Room reads EACH COLUMN by index
// Column order matches the CREATE TABLE statement
_result.add(_item);
}
return _result;
} finally {
_cursor.close();
}
}
}
);
// CoroutinesRoom.createFlow() is the KEY function:
// 1. Runs the query immediately → emits first result
// 2. Registers with InvalidationTracker to watch the "articles" table
// 3. When ANY write happens on "articles" → re-runs the query → emits new result
// This is HOW Room Flows are reactive!
}
// ═══ @Insert ═════════════════════════════════════════════════════
@Override
public Object insertArticles(List<ArticleEntity> articles, Continuation cont) {
// suspend functions get a Continuation parameter (CPS transformation)
return CoroutinesRoom.execute(
__db,
true, // inTransaction — writes are always in a transaction
new Callable<Void>() {
@Override
public Void call() {
__db.beginTransaction();
try {
// Room generates INSERT statements:
final SupportSQLiteStatement _stmt = __db.compileStatement(
"INSERT OR REPLACE INTO `articles` (`id`,`title`,`content`,`published_at`) VALUES (?,?,?,?)"
);
for (ArticleEntity item : articles) {
_stmt.bindString(1, item.getId());
_stmt.bindString(2, item.getTitle());
_stmt.bindString(3, item.getContent());
_stmt.bindLong(4, item.getPublishedAt());
_stmt.executeInsert();
}
__db.setTransactionSuccessful();
} finally {
__db.endTransaction();
}
return null;
}
},
cont // the Continuation — resumes the coroutine when done
);
}
}
// KEY INSIGHTS:
// 1. Room generates REAL implementation classes — no reflection
// 2. SQL strings are extracted from annotations AT COMPILE TIME
// 3. Cursor reading is column-by-column, index-based (fast)
// 4. suspend functions use CoroutinesRoom.execute() which handles threading
// 5. Flow queries use CoroutinesRoom.createFlow() which watches for table changes
CoroutinesRoom — How Threading Works
// When you call a suspend DAO function, Room uses CoroutinesRoom to handle threading
// CoroutinesRoom is an INTERNAL CLASS from room-ktx
// For WRITE operations (insert, update, delete):
CoroutinesRoom.execute(db, inTransaction = true, callable, continuation)
// 1. Switches to Room's transaction dispatcher
// 2. Begins a transaction
// 3. Runs your SQL
// 4. Commits the transaction
// 5. Notifies InvalidationTracker ("articles table changed!")
// 6. Resumes the coroutine via continuation
// For READ operations (suspend @Query):
CoroutinesRoom.execute(db, inTransaction = false, callable, continuation)
// 1. Switches to Room's query dispatcher
// 2. Runs the query
// 3. Maps cursor to objects
// 4. Resumes the coroutine with the result
// Room's internal dispatchers:
// - Transaction dispatcher: single-threaded, ensures write ordering
// Only ONE write operation at a time (serialized)
// - Query dispatcher: uses the Architecture Components IO executor
// Multiple reads can run in parallel
//
// This is WHY you don't need withContext(Dispatchers.IO) for Room:
// Room's suspend functions ALREADY switch to background threads internally
// Adding withContext(IO) is harmless but redundant
// For FLOW operations:
CoroutinesRoom.createFlow(db, inTransaction, tableNames, callable)
// 1. Runs the query immediately → emits first result
// 2. Registers an Observer on the InvalidationTracker for the specified tables
// 3. When a table is invalidated (write happened) → re-runs the query
// 4. Emits the new result to all collectors
// 5. When the Flow collector cancels → unregisters the observer
InvalidationTracker — How Room Flows Are Reactive
This is Room’s most important internal mechanism — it’s how Room knows when to re-emit a Flow:
// The InvalidationTracker is a CLASS inside RoomDatabase
// It watches tables for changes and notifies observers
// HOW IT WORKS:
//
// 1. Room creates a HIDDEN table called room_table_modification_log
// (or uses SQLite's built-in invalidation in newer versions)
//
// 2. For EVERY write operation (INSERT, UPDATE, DELETE),
// Room records WHICH TABLES were modified
// This happens inside the transaction — zero extra cost
//
// 3. After the transaction COMMITS, Room checks:
// "Were any observed tables modified?"
//
// 4. If YES → notifies all registered observers for those tables
// Observer callback → re-runs the Flow query → emits new result
//
// ═══ VISUAL FLOW ═════════════════════════════════════════════════════
//
// DAO.insertArticle(article)
// │
// ↓
// Room begins transaction
// │
// ↓
// INSERT INTO articles VALUES (...)
// │
// ↓
// Room marks "articles" table as INVALIDATED
// │
// ↓
// Room commits transaction
// │
// ↓
// InvalidationTracker checks: who's watching "articles"?
// │
// ↓
// Found: getAllArticles() Flow is watching!
// │
// ↓
// Re-runs: SELECT * FROM articles ORDER BY published_at DESC
// │
// ↓
// Flow emits the new list to all collectors
// │
// ↓
// UI updates automatically
// IMPORTANT: Room invalidates at the TABLE level, not ROW level
// If you insert article #123, Room re-runs ALL queries watching "articles"
// Even a query for "WHERE category = 'tech'" re-runs, even if #123 is 'sports'
// This is a trade-off: simple and reliable, but may cause unnecessary re-queries
// For most apps, this is fine — queries are fast and results are usually different
SupportSQLiteDatabase — The Abstraction Layer
// Room doesn't use Android's SQLiteDatabase directly
// It uses SupportSQLiteDatabase — an INTERFACE from androidx.sqlite
//
// WHY an abstraction?
// 1. Testability — in tests, you can swap in an in-memory database
// 2. Future-proofing — could switch to a different SQLite implementation
// 3. Consistency — cleaner API than Android's raw SQLiteDatabase
// The layer stack:
//
// Your @Dao interface
// ↓
// ArticleDao_Impl (generated)
// ↓
// RoomDatabase (your AppDatabase)
// ↓
// SupportSQLiteDatabase (INTERFACE — abstraction)
// ↓
// FrameworkSQLiteDatabase (IMPLEMENTATION — wraps Android's SQLiteDatabase)
// ↓
// Android SQLiteDatabase (the real database)
// ↓
// SQLite C library (native code, the actual engine)
// SupportSQLiteOpenHelper is an ABSTRACT CLASS
// It wraps Android's SQLiteOpenHelper and provides:
// - onCreate() → Room generates CREATE TABLE statements
// - onUpgrade() → Room runs your migrations
// - onOpen() → Room validates schema (identity_hash check)
// You can access the raw database for advanced operations:
val rawDb: SupportSQLiteDatabase = appDatabase.openHelper.writableDatabase
// openHelper is a PROPERTY on RoomDatabase — the SupportSQLiteOpenHelper
// writableDatabase is a PROPERTY — opens/returns the database
// ⚠️ Rarely needed — use DAO queries instead
WAL Mode — Write-Ahead Logging
// WAL (Write-Ahead Logging) is a SQLite journaling mode
// Room enables WAL by DEFAULT — and it's critical for performance
// WITHOUT WAL (old journal mode):
// ❌ Writers BLOCK readers — can't read while writing
// ❌ Only ONE operation at a time (serialize everything)
//
// WITH WAL (Room's default):
// ✅ Writers and readers work SIMULTANEOUSLY
// ✅ Reads don't block writes, writes don't block reads
// ✅ Multiple readers at the same time
//
// HOW WAL WORKS:
//
// Normal mode:
// Write → modify the database file directly → readers must wait
//
// WAL mode:
// Write → append to a SEPARATE log file (WAL file) → database file untouched
// Read → reads from database file (which is consistent) → not blocked by writes
// Checkpoint → WAL file merged back into database file (happens automatically)
//
// The database has THREE files:
// app.db → the main database file
// app.db-wal → the write-ahead log (pending writes)
// app.db-shm → shared memory file (for coordinating WAL)
// Room enables WAL automatically:
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.build()
// WAL is ON by default
// Disable WAL (rare — only if you need compatibility with old tools):
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
// JournalMode is an ENUM on RoomDatabase: AUTOMATIC, WRITE_AHEAD_LOGGING, TRUNCATE
// AUTOMATIC → Room decides (WAL on most devices)
// WRITE_AHEAD_LOGGING → force WAL
// TRUNCATE → old journal mode (no concurrent reads/writes)
.build()
// WHY THIS MATTERS:
// With WAL, your Flow queries can re-run while an insert is happening
// Without WAL, the Flow query blocks until the insert finishes
// WAL = smoother UI, especially during background syncs
RoomDatabase.Callback — Hooks into the Lifecycle
// RoomDatabase.Callback is an ABSTRACT CLASS
// It provides hooks for database creation, opening, and destructive migration
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addCallback(object : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
// Called ONCE when the database is FIRST CREATED (not on every open)
// Use for: inserting default/seed data
// db is the raw SupportSQLiteDatabase — you can run SQL here
db.execSQL("INSERT INTO categories (id, name) VALUES ('tech', 'Technology')")
db.execSQL("INSERT INTO categories (id, name) VALUES ('science', 'Science')")
// Pre-populate with default categories
// Or trigger a coroutine to load seed data:
// CoroutineScope(Dispatchers.IO).launch {
// database.articleDao().insertAll(defaultArticles)
// }
}
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
// Called EVERY TIME the database is opened
// Use for: enabling features, running maintenance
// ⚠️ Runs on the thread that opens the database — keep it fast
// Enable foreign key enforcement (not on by default in SQLite):
db.execSQL("PRAGMA foreign_keys = ON")
}
override fun onDestructiveRecreation(db: SupportSQLiteDatabase) {
super.onDestructiveRecreation(db)
// Called when fallbackToDestructiveMigration deletes and recreates
// Use for: logging, analytics ("user lost data!")
}
})
.build()
Pre-populated Databases — createFromAsset and createFromFile
// Sometimes you want to SHIP a database with your app
// (dictionary app, recipe app, map data, reference data)
// Instead of loading data on first launch, bundle a pre-built .db file
// ═══ createFromAsset — ship database in APK assets ═══════════════════
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.createFromAsset("databases/prepopulated.db")
// createFromAsset() is a FUNCTION on RoomDatabase.Builder
// "databases/prepopulated.db" is a file in your assets/ folder
// On first launch: Room copies this file AS the database
// Subsequent launches: uses the existing database (doesn't re-copy)
.build()
// The asset database MUST match your @Entity schema exactly!
// Same table names, same column names, same types
// Room validates the schema on open — mismatch → crash
// HOW TO CREATE the asset database:
// 1. Enable schema export: exportSchema = true in @Database
// 2. Build the app → Room generates schema JSON in schemas/1.json
// 3. Use the schema to create a .db file (via sqlitebrowser or a script)
// 4. Place the .db file in app/src/main/assets/databases/
// ═══ createFromFile — copy from device storage ═══════════════════════
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.createFromFile(File("/sdcard/Download/imported.db"))
// createFromFile() is a FUNCTION on RoomDatabase.Builder
// Copies from the specified file on first creation
// Use for: importing databases from downloads or backups
.build()
// COMBINING with migrations — pre-populated + updates:
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.createFromAsset("databases/v1.db") // start from asset
.addMigrations(MIGRATION_1_2, MIGRATION_2_3) // apply schema updates
.build()
// First install: copies v1.db, runs migrations to reach current version
// This lets you ship a large initial dataset and still evolve the schema
Schema Validation — The Identity Hash
// Room generates a HASH of your entire database schema:
// - Table names, column names, column types, primary keys, indices, foreign keys
// This hash is stored in room_master_table
// On every database OPEN:
// 1. Room reads the identity_hash from room_master_table
// 2. Room computes the hash of the CURRENT @Entity classes (compiled into the app)
// 3. Compares the two:
// MATCH → schema is consistent → proceed normally
// MISMATCH → schema changed without proper migration!
// → If fallbackToDestructiveMigration → delete and recreate
// → If no fallback → CRASH with IllegalStateException
// This is WHY you get the error:
// "Room cannot verify the data integrity. Looks like you've changed schema
// but forgot to update the version number."
//
// The fix: increment @Database(version = N+1) and add a migration
// The identity_hash also prevents:
// - Accidentally using a database file from a different schema version
// - Pre-populated databases that don't match the current schema
// - Corruption from manual SQL changes that Room doesn't know about
Room’s Compile-Time SQL Validation
// One of Room's BEST features: SQL is validated AT COMPILE TIME
// ❌ Typo in table name:
@Query("SELECT * FROM artcles") // "artcles" not "articles"
// COMPILE ERROR: "There is a problem with the query: no such table: artcles"
// ❌ Wrong column name:
@Query("SELECT * FROM articles WHERE ttle = :title") // "ttle" not "title"
// COMPILE ERROR: "no such column: ttle"
// ❌ Type mismatch:
@Query("SELECT * FROM articles WHERE published_at = :date")
fun getByDate(date: String): List<ArticleEntity>
// ⚠️ WARNING: "published_at" is INTEGER but you're comparing with String
// ❌ Missing parameter binding:
@Query("SELECT * FROM articles WHERE id = :articleId AND category = :cat")
fun get(articleId: String): ArticleEntity // missing "cat" parameter!
// COMPILE ERROR: "unused parameter: cat" or "missing parameter"
// HOW Room validates:
// 1. KSP reads the @Query SQL string
// 2. Room parses the SQL using its own SQLite parser
// 3. Room checks table names against @Entity classes
// 4. Room checks column names against @Entity properties
// 5. Room checks parameter bindings (:param) against function parameters
// 6. Room checks return type compatibility
// All at COMPILE TIME — errors show up as build errors, not runtime crashes
// This is a HUGE advantage over raw SQLite:
// Raw SQL: db.rawQuery("SELCT * FORM artcles", null) → runtime crash
// Room: @Query("SELCT * FORM artcles") → compile error → caught before release
Performance Internals
// How Room optimises database access:
//
// 1. PREPARED STATEMENTS
// Room pre-compiles SQL statements with compileStatement()
// The compiled statement is reused for repeated operations
// INSERT in a loop: compile once, bind + execute N times (much faster than N compiles)
//
// 2. CURSOR WINDOW MANAGEMENT
// SQLite returns data through a CursorWindow (a shared memory buffer)
// Room reads columns by INDEX (not by name) — faster column lookup
// Room closes cursors in finally blocks — no cursor leaks
//
// 3. TRANSACTION BATCHING
// @Insert with a List → Room wraps ALL inserts in ONE transaction
// Without transaction: 1000 inserts = 1000 disk flushes (slow!)
// With transaction: 1000 inserts = 1 disk flush (fast!)
//
// 4. INVALIDATION BATCHING
// Multiple writes in quick succession → ONE invalidation notification
// Room batches invalidation checks to avoid re-querying for every single write
//
// 5. LAZY DAO CREATION
// DAOs are created on first access, not on database creation
// appDatabase.articleDao() → lazy singleton pattern
//
// PERFORMANCE TIPS:
// ✅ Use @Transaction for multiple related writes
// ✅ Insert lists, not individual items: insertAll(list) not loop { insert(item) }
// ✅ Use indices on columns you filter/sort by
// ✅ Use LIMIT in queries if you only need a few rows
// ❌ Don't SELECT * if you only need a few columns (use a projection)
// ❌ Don't call suspend DAO functions in a tight loop — batch operations
Where to Find Generated Code
// Room's generated code is real, readable Java/Kotlin
// Looking at it helps you understand Room and debug issues
// Location:
// app/build/generated/ksp/debug/java/com/example/your/package/
// ├── AppDatabase_Impl.java ← database implementation
// ├── ArticleDao_Impl.java ← DAO implementation
// └── ...
// In Android Studio:
// 1. Build the project (Build → Make Project)
// 2. Navigate to your DAO interface
// 3. Cmd+Click (or Ctrl+Click) on a @Query method
// 4. Android Studio offers to navigate to the generated implementation
//
// OR:
// Open the "Build" tool window → look for generated sources
//
// Reading the generated code helps you:
// ✅ Understand exactly what SQL Room executes
// ✅ Debug unexpected query results
// ✅ Verify that your migration SQL matches Room's expectations
// ✅ Learn how Room handles threading and transactions
Common Mistakes to Avoid
Mistake 1: Thinking Room uses reflection
// ❌ "Room is slow because it uses reflection to read annotations"
// WRONG! Room generates code at COMPILE TIME via KSP
// At runtime, it's just regular method calls — zero reflection
// Room is as fast as hand-written SQLite code (because it IS generated code)
Mistake 2: Wrapping Room calls in withContext(Dispatchers.IO)
// ❌ Redundant — Room's suspend functions already switch threads
val articles = withContext(Dispatchers.IO) {
dao.getAllArticlesOnce() // Room already handles threading!
}
// ✅ Just call directly — Room switches internally
val articles = dao.getAllArticlesOnce()
// CoroutinesRoom.execute() handles the thread switching for you
Mistake 3: Not understanding table-level invalidation
// ❌ Expecting Row-level updates — "I only inserted ONE article, why does
// my getAllArticles() Flow re-emit ALL articles?"
// ANSWER: Room invalidates at the TABLE level, not row level
// ANY write to "articles" table → ALL queries watching "articles" re-run
// This is by design — simple, reliable, correct (even if slightly wasteful)
// ✅ This is fine for 99% of apps — queries are fast
// If it's a problem: use more specific queries or custom invalidation
Mistake 4: Not using transaction for multi-step operations
// ❌ Without transaction — each insert is a separate disk flush
for (article in articles) {
dao.insert(article) // disk flush for EACH article — very slow!
}
// ✅ Room batches list inserts in a transaction automatically
dao.insertAll(articles) // ONE transaction, ONE disk flush — fast!
// ✅ For custom multi-step: use @Transaction
@Transaction
suspend fun replaceAll(newArticles: List<ArticleEntity>) {
deleteAll()
insertAll(newArticles)
// Both run in ONE transaction — all or nothing
}
Mistake 5: Ignoring the schema export
// ❌ exportSchema = false — can't test migrations, no schema history
@Database(entities = [...], version = 3, exportSchema = false)
// ✅ exportSchema = true (default) — Room saves schema JSON for each version
@Database(entities = [...], version = 3, exportSchema = true)
// Schemas saved in: schemas/1.json, schemas/2.json, schemas/3.json
// Required for: auto-migrations and MigrationTestHelper
// Add to .gitignore? NO — commit these, they're your migration history
Summary
- Room generates real implementation classes at compile time via KSP — no reflection, no runtime overhead
- AppDatabase_Impl (generated class) extends your abstract database, creates tables, validates schema with identity hash
- ArticleDao_Impl (generated class) implements your DAO interface with actual SQL execution and cursor reading
- SupportSQLiteDatabase (interface from androidx.sqlite) is Room’s abstraction over Android’s SQLiteDatabase
- SupportSQLiteOpenHelper (abstract class) wraps SQLiteOpenHelper — handles creation, migration, and schema validation
- CoroutinesRoom (internal class from room-ktx) handles threading for suspend functions — you don’t need
withContext(IO) - InvalidationTracker (class inside RoomDatabase) watches tables for changes — this is how Room Flows are reactive
- Invalidation is at TABLE level, not row level — any write to a table re-triggers all queries watching it
- WAL mode (Write-Ahead Logging) is enabled by default — allows concurrent reads and writes
- RoomDatabase.Callback (abstract class) provides hooks:
onCreate()for seed data,onOpen()for maintenance createFromAsset()(function on Builder) ships a pre-populated database in your APK assetscreateFromFile()(function on Builder) copies a database from device storage on first creation- Room validates SQL at compile time — typos in table/column names are build errors, not runtime crashes
- Room uses prepared statements, transaction batching, and lazy DAO creation for performance
- The identity hash in room_master_table ensures schema consistency — mismatch means migration is needed
- Generated code is in
build/generated/ksp/— read it to understand exactly what Room does
Room’s magic is no magic at all — it’s code generation. KSP reads your annotations, generates Java classes that execute SQL, manage cursors, handle threading, and track table invalidation. Understanding this makes you better at using Room: you know why Flows re-emit (InvalidationTracker), why suspend is safe without withContext (CoroutinesRoom), why schema changes crash (identity hash), and where to look when things go wrong (generated code).
Happy coding!
Comments (0)