← Back to main index | ← Back to folder
🗂️ Room Architecture & ER Relationships
erDiagram
USERS ||--o{ POSTS : writes
USERS ||--o{ COMMENTS : creates
POSTS ||--o{ COMMENTS : receives
USERS {
int user_id PK
string name
string email
timestamp created_at
}
POSTS {
int post_id PK
int user_id FK
string title
string content
timestamp created_at
}
COMMENTS {
int comment_id PK
int post_id FK
int user_id FK
string text
timestamp created_at
}
🏗️ Room Architecture Layers
flowchart TB
subgraph App["Application Layer"]
VM["ViewModel"]
UC["UseCase"]
end
subgraph Repo["Repository"]
RI["Repository Interface"]
RImpl["RepositoryImpl"]
end
subgraph DB["Room Database"]
DAO["DAO (Data Access Object)"]
Entity["@Entity (Classes)"]
SQLite["SQLite Database File"]
end
VM --> UC
UC --> RI
RImpl --> DAO
DAO --> Entity
Entity --> SQLite
Warning
Room migrations handle schema version bumps.
- Define
Migration(oldVersion, newVersion)with SQL - Always test migrations (data loss + crashes common if untested)
- Use
@Query @PrimaryKeyto catch schema mismatches at compile-time
Migration path · ALTER TABLE · Schema versioning · Test migrations · Data loss risks
💻 Code Example
val database = Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
// Add new column
db.execSQL("ALTER TABLE users ADD COLUMN created_at INTEGER NOT NULL DEFAULT ${System.currentTimeMillis()}")
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
// Rename table
db.execSQL("ALTER TABLE users RENAME TO user_profiles")
// Create index for performance
db.execSQL("CREATE INDEX idx_user_email ON user_profiles(email)")
}
}Best practice: Test migrations!
💻 Code Example
@RunWith(AndroidTestRunner::class)
class MigrationTest {
@Rule val helper = MigrationTestHelper(InstrumentationRegistry.getContext(), AppDatabase::class.java)
@Test
fun migrate1To2() {
var db = helper.createDatabase("app.db", 1)
db.execSQL("INSERT INTO users VALUES (1, 'Alice')")
db.close()
db = helper.runMigrationsAndValidate("app.db", 2, true, MIGRATION_1_2)
val cursor = db.query("SELECT * FROM users")
assertTrue(cursor.moveToFirst())
}
}🔩 Under the Hood
What you CAN'T do in SQLite:
ALTER TABLE users DROP COLUMN email; // ❌ Not supported
ALTER TABLE users MODIFY COLUMN name TEXT; // ❌ Not supported
ALTER TABLE users CHANGE COLUMN ... // ❌ Not supported (MySQL only)SQLite workaround (copy pattern):
-- Step 1: Create new table with new schema
CREATE TABLE users_new (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
-- email column removed
);
-- Step 2: Copy data (no email)
INSERT INTO users_new (id, name) SELECT id, name FROM users;
-- Step 3: Drop old table
DROP TABLE users;
-- Step 4: Rename new table
ALTER TABLE users_new RENAME TO users;Room example:
val MIGRATION_drop_email = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
// Step 1-4 above
db.execSQL("CREATE TABLE users_new (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
db.execSQL("INSERT INTO users_new SELECT id, name FROM users")
db.execSQL("DROP TABLE users")
db.execSQL("ALTER TABLE users_new RENAME TO users")
db.execSQL("CREATE INDEX idx_user_name ON users(name)")
}
}In Room:
@Database(
entities = [User::class],
version = 3 // Current schema version
)
abstract class AppDatabase : RoomDatabase() { ... }On device (first run with version 3):
SQLite PRAGMA user_version = 3
↓
User opens app with version 3 code
↓
Room checks: version on device vs code version
↓
If match: no migration needed
↓
If device version < code version: apply migrations
↓
If device version > code version: error (new app, old DB)
Strategy 1: Linear chain
Version 1 → 2 → 3 → 4 (each migration supplied)
Most reliable; requires all intermediate migrations.
Strategy 2: Destructive (nuke old data)
.fallbackToDestructiveMigration() // Delete all dataFast, but users lose data. Only for dev/testing.
Strategy 3: Destructive on downgrade
.fallbackToDestructiveMigrationOnDowngrade()User downgraded app → nuke DB. Risky (data loss).
- SQLite PRAGMA user_version — stores schema version on device
- SQLite transaction system — migrations atomic (all-or-nothing)
- Room schema inspection —
.schemafile tracks entity definitions - Cursor/ResultSet — iterates rows for copy-paste logic
Problem: App updates add/change schema. Users' old DBs must upgrade without losing data.
Solution: Migrations.
- Version number tracks schema state
- Migrations explicitly define transformation
- Atomic transactions ensure consistency
| A user knows | An understander also knows |
|---|---|
| "Add Migration for schema changes" | Room reads PRAGMA user_version; runs migrations in order until current version reached. |
| "Test migrations" | MigrationTestHelper creates old DB, applies migration, verifies schema. Catches data loss bugs. |
| "Can't drop columns in SQLite" | SQLite lacks DROP COLUMN. Workaround: copy table (with new schema), drop old, rename new. |
| "Fallback to destructive" | Nukes DB if migration not provided. Users lose data. Only use for dev. |
- Missing migration: If user on version 1, app is version 3, but migration 2→3 missing. Room crashes. Always supply all intermediate migrations.
- Data type changes: Changing column type (TEXT → INTEGER) requires copy (no direct ALTER). Conversion bugs (invalid cast) crash migration.
- Foreign key constraints: ALTER TABLE with FK references risky. Disable FK temporarily:
PRAGMA foreign_keys = OFF, then re-enable. - Performance: Large tables (millions of rows) = slow migrations (copy entire table). Migration can take minutes; user sees "app updating..." UI.
Tip
N+1 = fetch 1 user, then N queries for posts (1 + N queries total). Fix: use JOIN in SQL or Room @Relation (auto-joins). Massive performance difference at scale.
N+1 problem · JOIN optimization · @Relation auto-join · Scale matters · Single query vs N queries
💻 Code Example
// ❌ N+1 Performance Disaster
val users = db.getUsers() // Query 1
for (user in users) {
user.posts = db.getPostsByUserId(user.id) // Queries 2...N+1 (one per user)
}
// Total: 1 + N queries. With 100 users = 101 queries!
// ✅ Solution 1: SQL JOIN (single query)
@Query("""
SELECT u.*, p.* FROM users u
LEFT JOIN posts p ON u.id = p.user_id
""")
suspend fun getUsersWithPosts(): List<UserWithPosts>
// ✅ Solution 2: Room @Relation (Room handles join)
@Query("SELECT * FROM users")
suspend fun getUsersWithPosts(): List<UserWithPosts>| Problem | Queries | Time (100 users) |
|---|---|---|
| N+1 | 101 (1 + 100) | ~500ms (100 DB round-trips) |
| JOIN | 1 | ~5ms (single query) |
| Speedup | ~100x | ~100x improvement |
🔩 Under the Hood
Query plan (N+1):
SELECT * FROM users -- 1 scan: full table scan → 100 rows
-- Application loops:
SELECT * FROM posts WHERE user_id = 1 -- Round-trip to DB
SELECT * FROM posts WHERE user_id = 2 -- Round-trip to DB
...
SELECT * FROM posts WHERE user_id = 100 -- Round-trip to DB
Total: 101 queries, 101 round-tripsQuery plan (JOIN):
SELECT u.id, u.name, p.id, p.title, p.user_id
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
-- Single query execution:
-- 1. Scan users table (100 rows)
-- 2. For each row, find matching posts (B-tree index lookup on user_id)
-- 3. Return all rows (1 query result, multiple rows per user)
Total: 1 query, 1 round-tripWhat you write:
data class UserWithPosts(
@Embedded val user: User,
@Relation(
parentColumn = "id",
entityColumn = "user_id"
)
val posts: List<Post>
)
@Query("SELECT * FROM users WHERE id = :userId")
suspend fun getUserWithPosts(userId: Int): UserWithPostsWhat Room generates:
// Room creates wrapper query:
suspend fun getUserWithPosts(userId: Int): UserWithPosts {
val user = db.query("SELECT * FROM users WHERE id = ?", userId)
val posts = db.query("SELECT * FROM posts WHERE user_id = ?", user.id)
return UserWithPosts(user, posts)
}Note: @Relation still does 2 queries (one per entity type), but Room batches them efficiently (not loop-based N+1).
Better than @Relation (for lists):
@Query("""
SELECT u.*, p.* FROM users u
LEFT JOIN posts p ON u.id = p.user_id
ORDER BY u.id, p.id
""")
suspend fun getUsersWithPostsBatch(): List<UserWithPosts>
// Room cursor parsing:
// Cursor rows: (user1, post1), (user1, post2), (user2, post3)...
// Room groups by user, builds UserWithPosts objects
// Single query, cursor handles grouping- SQLite query optimizer — chooses index-based join strategy
- B-tree indices — fast lookups on foreign key columns
- Cursor/ResultSet — iterates query results
- Room cursor parsing — groups results by parent entity
Scaling:
- 10 users: 11 queries (not noticeable)
- 100 users: 101 queries (visible lag)
- 1000 users: 1001 queries (crash/timeout)
Network overhead: Each query = HTTP round-trip (if remote). 100 queries = 100 round-trips (seconds of latency).
SQLite overhead: Each query = parsing + planning + execution. 100 simple queries slower than 1 complex query (plan reuse).
| A user knows | An understander also knows |
|---|---|
| "N+1 is bad" | N+1 = 1 query (parent) + N queries (children). Total = N+1 queries. Cursor/network round-trips = O(N). |
| "Use JOIN to fix" | JOIN combines tables in single query. SQLite index-joins parent+children efficiently (B-tree lookups). Single query = single round-trip. |
| "@Relation does auto-join" | Room still issues 2 queries (one per entity), but batches them (not loop-based). Still faster than N+1 loop. |
| "Performance matters" | 100x difference at scale. 100 queries = seconds. 1 query = milliseconds. |
- JOIN result explosion: If user has 100 posts, JOIN returns 100 rows (one per post). Room cursor parsing groups back into 1 UserWithPosts. Large result sets can be slow.
- LEFT vs INNER: LEFT JOIN keeps users without posts. INNER JOIN drops them. Choose based on use case (usually LEFT for "get all users").
- Complex grouping: Room handles simple 1-to-N. Complex N-to-N relationships need manual cursor handling (rare but possible).
- Pagination with JOIN: LIMIT 10 on JOIN counts result rows (100 posts per user = only 1 user returned). Use DISTINCT or window functions.
Tip
@Transaction wraps multiple queries in SQLite transaction.
- All succeed or all fail
- Use for multi-step operations (insert user → insert posts)
- Atomicity prevents partial updates (user created, posts failed = data corruption)
ACID properties · All-or-nothing · Rollback on error · SQLite transaction · @Transaction annotation
💻 Code Example
@Transaction // All-or-nothing
suspend fun updateUserAndPosts(user: User, posts: List<Post>) {
insertUser(user) // Succeeds
insertPosts(posts) // Fails → rollback both
// If any fails, entire operation reverts
}
// Manual transaction
db.withTransaction {
insertUser(user)
insertPosts(posts)
}🔩 Under the Hood
Atomicity (all-or-nothing):
db.withTransaction {
insertUser(User(1, "Alice")) // Step 1
insertPost(Post(1, 1, "Hello")) // Step 2
throw Exception("Oops!") // Step 3: ERROR
// Rollback: both insert operations undone
}
// User AND Post NOT in DB (atomic)Consistency (valid state before & after):
-- Before transaction:
-- users: [User(1, "Alice")]
-- posts: []
-- Constraint: every post.user_id must exist in users
-- During transaction (ERROR):
-- Cannot create post with non-existent user_id
-- Constraint violation prevented
-- After transaction (rolled back):
-- Same state as before (consistent)Isolation (concurrent transactions):
User A transaction: START → read user balance (100)
↓
User B transaction: START → deduct 50 (balance now 50)
↓
User A transaction: deduct 30 → ?
// A sees 100 or 50? Depends on isolation level
Durability (written to disk):
INSERT user → SQLite writes to disk
↓
Power loss / crash
↓
On recovery: INSERT persisted (durable)
What Room generates:
@Transaction
suspend fun updateUserAndPosts(user: User, posts: List<Post>) {
// Room generates:
// BEGIN TRANSACTION
insertUser(user)
insertPosts(posts)
// COMMIT (if no errors)
// or ROLLBACK (if exception)
}Manual equivalent:
BEGIN TRANSACTION;
INSERT INTO users VALUES (...);
INSERT INTO posts VALUES (...);
-- If error anywhere:
ROLLBACK;
-- If success:
COMMIT;Read Uncommitted (dirty reads possible):
Transaction A: INSERT user(balance=100)
Transaction B: SELECT balance → 100 (before commit)
Transaction A: ROLLBACK
// B saw "dirty" data (never committed)
Read Committed (default in SQLite via WAL):
Transaction A: INSERT user(balance=100), COMMIT
Transaction B: SELECT balance → 100 (only committed data)
// B only sees committed data (safe)
SQLite isolation (WAL mode):
- Write-Ahead Logging (WAL)
- Readers see consistent snapshot
- Writers don't block readers (separate WAL file)
- Room uses WAL by default
Exception triggers rollback:
db.withTransaction {
insertUser(user) // Success: in transaction buffer
throw IOException() // Exception: ROLLBACK triggered
insertPost(post) // Never reached
}
// Both insertions undone (atomic)Resource cleanup (try-finally):
db.withTransaction {
try {
insertUser(user)
insertPost(post)
} finally {
// Cleanup code (transaction still active)
// Rollback happens AFTER finally
}
}- SQLite transaction system — BEGIN/COMMIT/ROLLBACK
- Write-Ahead Logging (WAL) — isolation + concurrency
- SQLite savepoints — nested transactions (savepoint/release)
- Room suspend functions — async I/O with coroutine scope
Problem: Multiple operations must be all-or-nothing. One failure = data corruption.
Example: Transferring money
Account A: -$100
Account B: +$100
// Both must succeed, or both must fail
// If B fails after A debited: money vanishes
Solution: Transactions.
- Wrap all operations
- If any fails: rollback all
- Database stays consistent
| A user knows | An understander also knows |
|---|---|
| "@Transaction makes all-or-nothing" | Room wraps function in BEGIN/COMMIT (or ROLLBACK). SQLite atomically applies all or none. |
| "Rollback undoes changes" | SQLite reverts all changes to transaction snapshot if error occurs. |
| "Concurrent transactions" | WAL mode allows readers/writers simultaneously (readers see old snapshot, writers create new). |
| "Savepoints for nesting" | SQLite SAVEPOINT allows nested transactions (outer commits, inner can rollback). |
- Transaction timeout: Very long transactions (millions of inserts) may timeout. Batch into smaller transactions if needed.
- Deadlock on nested transactions: Two transactions wait for each other's locks. SQLite raises SQLITE_IOERR. Careful with
@Transactioncalling another@Transaction. - Corrupted DB after crash: If crash during COMMIT, DB may be corrupt. WAL journal files must be cleaned. Room handles via
databaseBuilder().fallbackToDestructiveMigration()on corruption. - Size growth: WAL files (.wal, .shm) grow alongside main DB. Not automatically cleaned. Use
pragma wal_checkpoint(RESTART)to consolidate.