Room Database

DataStore is great for a handful of loose settings, but TrailLog's real data — the growing list of logged trails — needs something built for structured, queryable storage. Room is Jetpack's ORM (object-relational mapper) sitting on top of SQLite. Instead of writing raw SQL strings by hand and hoping they're correct, Room lets you declare your schema and queries as Kotlin code that's checked at compile time — a typo in a column name fails the build instead of crashing at runtime.

Piece One: The @Entity

An @Entity is a plain data class annotated to describe one database table. Each property becomes a column, and one property is marked as the primary key:

Trail.kt — TrailLog's Room entity

@Entity
data class Trail(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val name: String,
    val notes: String,
    val photoUri: String?,
    val latitude: Double,
    val longitude: Double,
    val distanceKm: Double,
    val date: Long
)

@Entity tells Room this class maps to a table (named Trail by default, matching the class name). @PrimaryKey(autoGenerate = true) means Room assigns each new row its own unique id automatically, so callers can pass 0 and never worry about picking an id themselves.

Piece Two: The @Dao

A @Dao (Data Access Object) is an interface that declares the queries you're allowed to run against the table — Room generates the actual implementation for you at compile time:

TrailDao.kt

@Dao
interface TrailDao {

    @Insert
    suspend fun insertTrail(trail: Trail)

    @Query("SELECT * FROM Trail ORDER BY date DESC")
    fun getAllTrails(): Flow<List<Trail>>

    @Delete
    suspend fun deleteTrail(trail: Trail)
}

The genuinely powerful detail here is getAllTrails() returning Flow<List <Trail>> directly. Room watches the underlying table and automatically emits a fresh list through that Flow the instant any row changes — insert a trail anywhere in the app, and every screen collecting this Flow updates on its own, with no manual "refresh" call needed anywhere.

Piece Three: The @Database

The @Database abstract class ties the entities and DAOs together into one database definition, and is where you get an actual RoomDatabase instance from:

TrailDatabase.kt

@Database(entities = [Trail::class], version = 1)
abstract class TrailDatabase : RoomDatabase() {

    abstract fun trailDao(): TrailDao

    companion object {
        @Volatile
        private var INSTANCE: TrailDatabase? = null

        fun getInstance(context: Context): TrailDatabase {
            return INSTANCE ?: synchronized(this) {
                INSTANCE ?: Room.databaseBuilder(
                    context.applicationContext,
                    TrailDatabase::class.java,
                    "trail_database"
                ).build().also { INSTANCE = it }
            }
        }
    }
}

The companion object singleton pattern guarantees only one TrailDatabase (and one underlying SQLite connection) ever exists for the app's lifetime — building a second instance elsewhere would waste resources and risk two connections stepping on each other.

Why Room Insists on suspend and Flow

Notice every write in TrailDao is a suspend fun, and the read is a Flow — Room deliberately does not offer a plain blocking call for either. Database I/O reads from and writes to disk, which takes measurable time; running it directly on the main thread would freeze TrailLog's UI while it waits, and Android will actually crash the app with an exception if you try. Requiring suspend or Flow forces every call through a coroutine, which Coroutines Basics covered as the whole reason structured concurrency exists — moving work off the main thread without manual thread juggling.

Evolving the Schema: A Brief Word on Migrations

Real apps change their schema over time — TrailLog might later add an elevationGainM column to Trail. Bumping the version number in @Database without a plan simply destroys the existing table by default, wiping out every trail a user has already logged. Room's migration system lets you supply the exact SQL needed to carry old data forward into the new schema instead. This is conceptual awareness for now — a full migration walkthrough is beyond this lesson's scope, but knowing it exists (and that skipping it deletes user data) is essential before shipping any schema change.

Note: All the Kotlin above is correct, real Room code, but this website's own project doesn't run a live Android Studio build, so no query actually executes on this page.
Next up: Room handles TrailLog's structured trail data — but a trail's actual photo file needs a different kind of storage. Files: Internal & External covers that next.