How Room Database Implements Caching for Offline Support in gsygithubappcompose

The gsygithubappcompose Android app uses Room with a read-through cache pattern that first emits local database results marked as DataSource.CACHE, then refreshes from the network and updates the SQLite tables atomically to provide seamless offline support.

The gsygithubappcompose repository demonstrates a production-grade offline-first architecture using Android Room as a local persistence layer. By implementing a read-through caching strategy, the app ensures users can view GitHub data instantly from local storage while maintaining synchronization with remote APIs when connectivity is available.

Room Database Architecture and Setup

The caching infrastructure begins with dependency injection in DatabaseModule.kt. The module provides a singleton AppDatabase using Room.databaseBuilder(), configured with fallbackToDestructiveMigration(true) to handle schema changes gracefully by clearing the cache rather than crashing legacy installations.

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Provides
    @Singleton
    fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
        return Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "github_app.db"
        )
        .fallbackToDestructiveMigration(true)
        .build()
    }
}

AppDatabase declares the full schema including RepositoryEntity, CommitEntity, and UserEntity, and exposes DAO getters for each domain area. This centralized database class serves as the single source of truth for all local persistence operations.

Entity Schema and DAO Layer with Flow

Each entity represents a cacheable domain object stored in SQLite. The Data Access Objects (DAOs) leverage Kotlin Flow to provide reactive streams of cached data. In RepositoryDao.kt, the getTrendingRepositories() method returns a Flow<List<RepositoryEntity>>, allowing the UI layer to observe database changes automatically.

@Dao
interface RepositoryDao {
    @Query("SELECT * FROM repositories WHERE is_trending = 1")
    fun getTrendingRepositories(): Flow<List<RepositoryEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertRepositories(repositories: List<RepositoryEntity>)

    @Query("DELETE FROM repositories WHERE is_trending = 1")
    suspend fun clearTrendingRepositories()

    @Transaction
    suspend fun clearAndInsert(repositories: List<RepositoryEntity>) {
        clearTrendingRepositories()
        insertRepositories(repositories)
    }
}

The OnConflictStrategy.REPLACE annotation ensures that duplicate entries are updated rather than causing insert failures, while the clearAndInsert method uses @Transaction to guarantee atomic cache refreshes—either the entire operation succeeds or the database remains unchanged.

Read-Through Cache Pattern Implementation

The core offline logic resides in repository classes such as RepositoryRepository.kt. This implements a read-through cache where the local Room database is treated as the primary data source, with network calls serving as refresh operations.

The pattern executes in three phases:

  1. Cache First: Query the DAO and immediately emit cached results with DataSource.CACHE flag
  2. Network Refresh: Fetch fresh data from the GitHub API
  3. Cache Update: Write the new data to Room and emit updated results with DataSource.NETWORK
fun getTrendingRepositories(language: String? = null, page: Int = 1) = flow {
    // Phase 1: Emit cached data immediately for offline support
    val cached = repositoryDao.getTrendingRepositories().first()
    if (cached.isNotEmpty()) {
        emit(RepositoryResult(
            Result.success(cached.map { it.toRepository() }),
            DataSource.CACHE,
            isDbEmpty = false
        ))
    }

    // Phase 2: Fetch from network
    val query = buildQuery(language)
    val response = apiService.searchRepositories(query, page)

    // Phase 3: Update cache atomically on first page
    if (page == 1) {
        repositoryDao.clearAndInsert(
            response.items.map { it.toEntity() }
        )
    }

    emit(RepositoryResult(
        Result.success(response.items),
        DataSource.NETWORK,
        isDbEmpty = cached.isEmpty()
    ))
}

This approach ensures the UI never waits for network I/O when valid cache exists, while maintaining data freshness through background synchronization.

Transactional Cache Updates

Room's @Transaction annotation plays a critical role in maintaining cache integrity. The clearAndInsert pattern prevents partial data states during updates—critical for offline reliability where users may close the app mid-sync.

@Dao
interface CommitDao {
    @Query("SELECT * FROM commits WHERE owner = :owner AND repo = :repo")
    fun getRepoCommits(owner: String, repo: String): Flow<List<CommitEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertCommits(commits: List<CommitEntity>)

    @Query("DELETE FROM commits WHERE owner = :owner AND repo = :repo")
    suspend fun deleteByOwnerAndRepo(owner: String, repo: String)

    @Transaction
    suspend fun clearAndInsertRepoCommits(
        owner: String, 
        repo: String, 
        commits: List<CommitEntity>
    ) {
        deleteByOwnerAndRepo(owner, repo)
        insertCommits(commits)
    }
}

The fallbackToDestructiveMigration(true) configuration in DatabaseModule.kt complements this strategy by ensuring that schema version mismatches result in a clean cache wipe rather than application crashes, treating the local database as a disposable cache rather than critical persistent storage.

Summary

  • Room serves as the local persistence layer in gsygithubappcompose, configured as a singleton via Hilt in DatabaseModule.kt
  • Reactive caching uses DAO methods returning Flow<List<Entity>>, enabling automatic UI updates when cache changes
  • Read-through pattern prioritizes local data availability by emitting DataSource.CACHE results before network requests complete
  • Atomic updates via @Transaction ensure cache consistency during refresh operations using clearAndInsert methods
  • Conflict resolution uses OnConflictStrategy.REPLACE to handle duplicate entries during cache synchronization
  • Schema evolution employs fallbackToDestructiveMigration(true) to gracefully handle database version changes by clearing stale cache data

Frequently Asked Questions

How does the app handle offline scenarios when no network is available?

When offline, the repository classes emit cached data immediately from Room via Flow.first(), marked with DataSource.CACHE. The network request fails silently or throws an exception caught by the calling ViewModel, but the user retains access to previously fetched data stored in the local SQLite database.

What happens when the database schema changes in an update?

The AppDatabase configuration in DatabaseModule.kt sets fallbackToDestructiveMigration(true), which destroys and recreates the database when migration paths are unavailable. This is acceptable for a cache layer because the data can be re-fetched from the GitHub API, though it requires users to reload content after app updates.

Why does the repository use clearAndInsert instead of upserting individual items?

The clearAndInsert pattern wrapped in @Transaction ensures atomic cache replacement. This prevents scenarios where stale data persists if the insert operation partially fails or if items were deleted from the remote source. It guarantees that the local cache exactly mirrors the API response for that specific query page.

How does the UI distinguish between cached and fresh data?

The repository emits a RepositoryResult wrapper containing a DataSource enum indicating CACHE or NETWORK. The UI layer observes this field to display subtle indicators (such as timestamp labels or refresh animations) informing users whether they are viewing potentially stale offline data or live synchronized content.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →