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 assets
  • createFromFile() (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!