Share
4 April 2026

Building a Production-Grade Anime Browser with Room DB, Clean Architecture & Jetpack Compose

A deep dive into offline-first Android development — from SQLite basics to use cases, @Relation, and cinematic dark UI



Why I Built This

Every Android tutorial teaches you how to use Room. Few show you how to use it properly — with a clean architecture, a real offline-first strategy, proper cache invalidation, and a UI that actually communicates what’s happening under the hood.

This article walks through building a complete Anime Browser app that:

  • Fetches data from the Jikan API (free MyAnimeList wrapper, no key needed)
  • Caches everything in Room DB with a 30-minute TTL
  • Works fully offline with stale-data fallback
  • Uses Clean Architecture — data, domain, and presentation layers with zero cross-contamination
  • Shows the user exactly where their data came from and when

The Problem with “Learning Room” Projects

Most Room tutorials look like this:

// The tutorial way
val db = Room.databaseBuilder(context, AppDatabase::class.java, "db").build()
val users = db.userDao().getAll()

That’s fine for understanding the API. It’s terrible for production. The ViewModel imports the DAO directly. There’s no caching strategy. The UI has no idea if it’s showing live or stale data. When you go offline, everything breaks.

Let’s do it properly.


Architecture Overview

The app follows Clean Architecture with three strict layers:

Presentation  →  Domain  ←  Data

The arrows tell the whole story. Presentation talks to Domain. Data implements Domain. Presentation never touches Data directly. That’s enforced in code — the ViewModel imports zero classes from data.*.

The Layers in Practice

Data layer owns:

  • AnimeEntity + FavoriteEntity — Room tables
  • AnimeDao — all SQL queries
  • AppDatabase — the Room entry point
  • AnimeRepository — implements IAnimeRepository
  • Retrofit API interface and DTOs

Domain layer owns:

  • IAnimeRepository — the contract (interface)
  • AnimeResultAnimeAnimeFavorite — pure Kotlin models
  • Six use cases — one responsibility each

Presentation layer owns:

  • AnimeViewModel — only imports domain
  • Compose screens and components
  • Theme and styling

Room DB — What This App Actually Teaches

Two Tables with @Relation

Most tutorials have one table. This app has two — and joins them with @Relation:

@Entity(tableName = "anime_cache",
    indices = [Index(value = ["malId", "category"], unique = true)])
data class AnimeEntity(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val malId: Int,
    val title: String,
    // ...
    val genres: String,    // stored as JSON via TypeConverter
    val studios: String,
    val category: String,
    val cachedAt: Long = System.currentTimeMillis()
)

@Entity(tableName = "anime_favorites")
data class FavoriteEntity(
    @PrimaryKey val malId: Int,
    val title: String,
    val imageUrl: String,
    val score: Double?,
    val addedAt: Long = System.currentTimeMillis()
)

The join POJO — this is the @Relation magic:

data class AnimeWithFavorite(
    @Embedded val anime: AnimeEntity,
    @Relation(
        parentColumn = "malId",
        entityColumn = "malId"
    )
    val favorite: FavoriteEntity?  // null = not favorited
)

When you query AnimeWithFavorite, Room executes two queries atomically (protected by @Transaction) and assembles the result for you. No JOIN syntax. No cursor manipulation.

TypeConverters for List<String>

Room can’t store List<String> natively. Enter @TypeConverters:

class AnimeConverters {
    private val gson = Gson()

    @TypeConverter
    fun fromStringList(list: List<String>): String = gson.toJson(list)

    @TypeConverter
    fun toStringList(json: String): List<String> {
        val type = object : TypeToken<List<String>>() {}.type
        return gson.fromJson(json, type)
    }
}

Register it once at the database level — Room applies it globally:

@Database(entities = [AnimeEntity::class, FavoriteEntity::class], version = 1)
@TypeConverters(AnimeConverters::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun animeDao(): AnimeDao
}

The DAO — Flow for Live Data

@Dao
interface AnimeDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(anime: List<AnimeEntity>)

    // Flow = auto-updates UI when DB changes
    @Query("SELECT * FROM anime_cache WHERE category = :category ORDER BY rank ASC")
    fun observeByCategory(category: String): Flow<List<AnimeEntity>>

    @Query("SELECT COUNT(*) FROM anime_favorites WHERE malId = :malId")
    fun isFavorite(malId: Int): Flow<Int>

    @Transaction
    @Query("SELECT * FROM anime_cache WHERE category = :category")
    fun observeWithFavorites(category: String): Flow<List<AnimeWithFavorite>>

    @Query("DELETE FROM anime_cache WHERE cachedAt < :before")
    suspend fun deleteOlderThan(before: Long)
}

The key insight: functions returning Flow should not be suspend. Room manages the coroutine internally. Only one-shot reads like getById() are suspend.


The Offline-First Repository

This is where the real architecture lives. The strategy:

  1. Emit cached data immediately — UI shows something in milliseconds
  2. Check cache age — if fresh (< 30 min), stop there
  3. If stale and online — fetch from Jikan, replace cache, emit fresh data
  4. If offline — show stale data with a clear error message
class AnimeRepository(
    private val api: AnimeApi,
    private val dao: AnimeDao,
    private val connectivity: ConnectivityObserver
) : IAnimeRepository {

    override fun getAnimeByCategory(
        category: AnimeCategory,
        forceRefresh: Boolean
    ): Flow<AnimeResult> = flow {

        emit(AnimeResult.Loading)

        // Step 1: Emit cache immediately
        val cached = dao.observeByCategory(category.name).first()
        val cacheTimestamp = cached.firstOrNull()?.cachedAt ?: 0L
        if (cached.isNotEmpty()) {
            emit(AnimeResult.Success(cached.map { it.toDomain() },
                fromCache = true, cachedAt = cacheTimestamp))
        }

        // Step 2: Freshness check
        val age = System.currentTimeMillis() - cacheTimestamp
        if (age < CACHE_TTL_MS && !forceRefresh && cached.isNotEmpty()) return@flow

        // Step 3: Network fetch
        if (!connectivity.isOnline()) {
            emit(AnimeResult.Error("Offline — showing ${cached.size} cached anime", cached.map { it.toDomain() }))
            return@flow
        }

        try {
            val response = api.getTopAnime(filter = category.endpoint, limit = 20)
            val entities = response.data.map { it.toEntity(category) }
            dao.deleteByCategory(category.name)
            dao.insertAll(entities)
            emit(AnimeResult.Success(entities.map { it.toDomain() },
                fromCache = false, cachedAt = System.currentTimeMillis()))
        } catch (e: Exception) {
            emit(AnimeResult.Error("Network error — showing cached data", cached.map { it.toDomain() }))
        }
    }
}

Notice AnimeResult lives in the domain layer — not in data. The repository returns a domain type. Presentation never needs to know what’s behind it.


The Repository Interface — The Abstraction Boundary

// domain/repository/IAnimeRepository.kt
interface IAnimeRepository {
    fun getAnimeByCategory(category: AnimeCategory, forceRefresh: Boolean = false): Flow<AnimeResult>
    fun observeFavorites(): Flow<List<AnimeFavorite>>
    fun isFavorite(malId: Int): Flow<Int>
    fun favoriteCount(): Flow<Int>
    suspend fun toggleFavorite(anime: Anime)
    suspend fun totalCachedCount(): Int
    suspend fun cachedCategoryCount(): Int
}

AnimeRepository implements this. But the ViewModel never sees AnimeRepository. It only sees the interface — injected via Koin:

// di/AppModule.kt
single<IAnimeRepository> { AnimeRepository(get(), get(), get()) }

Swap the implementation for a mock, a different API, or a test double — zero changes to ViewModel or UI.


Use Cases — One Responsibility Each

Six use cases, each wrapping exactly one repository operation:

class GetAnimeByCategoryUseCase(private val repository: IAnimeRepository) {
    operator fun invoke(category: AnimeCategory, forceRefresh: Boolean = false): Flow<AnimeResult> =
        repository.getAnimeByCategory(category, forceRefresh)
}

class ToggleFavoriteUseCase(private val repository: IAnimeRepository) {
    suspend operator fun invoke(anime: Anime) = repository.toggleFavorite(anime)
}

class CheckIsFavoriteUseCase(private val repository: IAnimeRepository) {
    operator fun invoke(malId: Int): Flow<Boolean> =
        repository.isFavorite(malId).map { it > 0 }
}

data class DbStats(val totalCached: Int, val cachedCategories: Int)

class GetDbStatsUseCase(private val repository: IAnimeRepository) {
    suspend operator fun invoke() = DbStats(
        totalCached = repository.totalCachedCount(),
        cachedCategories = repository.cachedCategoryCount()
    )
}

operator fun invoke() lets callers write getAnimeByCategory(tab) instead of getAnimeByCategory.execute(tab). It reads like a function call, which is exactly what it is.


The ViewModel — Clean Separation Enforced

class AnimeViewModel(
    // ✅ Only use cases — zero data.* imports
    private val getAnimeByCategory: GetAnimeByCategoryUseCase,
    private val toggleFavorite: ToggleFavoriteUseCase,
    private val observeFavoritesUseCase: ObserveFavoritesUseCase,
    private val checkIsFavorite: CheckIsFavoriteUseCase,
    private val getFavoriteCount: GetFavoriteCountUseCase,
    private val getDbStats: GetDbStatsUseCase,
    private val connectivity: ConnectivityObserver
) : ViewModel() {

    val favorites: StateFlow<List<AnimeFavorite>> = observeFavoritesUseCase()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    private fun loadCurrentTab(forceRefresh: Boolean = false) {
        viewModelScope.launch {
            getAnimeByCategory(_selectedTab.value, forceRefresh).collect { result ->
                when (result) {
                    is AnimeResult.Loading -> { /* update loading state */ }
                    is AnimeResult.Success -> { /* update UI with data */ }
                    is AnimeResult.Error   -> { /* show error, keep stale data */ }
                }
            }
        }
    }
}

The ViewModel import block has zero occurrences of data. — that’s the enforcement mechanism.


Koin DI — Wiring It All Together

val appModule = module {
    // Room
    single {
        Room.databaseBuilder<AppDatabase>(context = androidContext(), name = "anime_db")
            .fallbackToDestructiveMigration(dropAllTables = true)
            .build()
    }
    single { get<AppDatabase>().animeDao() }

    // Retrofit
    single { /* OkHttpClient */ }
    single { /* Retrofit -> AnimeApi */ }

    // Utils
    single { ConnectivityObserver(androidContext()) }

    // Repository bound to INTERFACE — the key line
    single<IAnimeRepository> { AnimeRepository(get(), get(), get()) }

    // Use cases — factory (new instance per injection, stateless)
    factory { GetAnimeByCategoryUseCase(get()) }
    factory { ToggleFavoriteUseCase(get()) }
    factory { ObserveFavoritesUseCase(get()) }
    factory { CheckIsFavoriteUseCase(get()) }
    factory { GetFavoriteCountUseCase(get()) }
    factory { GetDbStatsUseCase(get()) }

    // ViewModel receives use cases, not repository
    viewModel { AnimeViewModel(get(), get(), get(), get(), get(), get(), get()) }
}

Use cases are factory {} not single {} — they’re stateless, so creating a new instance per injection is cheap and avoids shared mutable state.


The UI — Cache Status as a First-Class Citizen

Most apps hide caching from users. This app makes it explicit — because it’s educational, but also because transparency builds trust.

@Composable
private fun CacheStatusBar(fromCache: Boolean, cachedAt: Long, ...) {
    val accentColor = if (fromCache) GoldScore else AquaMint

    Row(
        modifier = Modifier
            .background(accentColor.copy(0.07f))
            .padding(16.dp)
    ) {
        Icon(
            imageVector = if (fromCache) Icons.Outlined.Storage else Icons.Outlined.CloudDone,
            tint = accentColor
        )
        Column {
            Text(if (fromCache) "Room DB Cache" else "Jikan API → Room", color = accentColor)
            Text(TimeUtils.fullDateTime(cachedAt), color = White25)
        }
        // DB stats pills: 20 anime · 60 cached · 3 fav
    }
}

Gold = cache. Mint = live network. The user always knows exactly what they’re looking at and when it was fetched.


The Favorites Screen — @Relation in Action

The favorites screen demonstrates why the two-table design matters. When you add a favorite, the anime_favorites table is written independently of anime_cache. That means:

  • Favorites survive cache expiry
  • Favorites persist after you deleteOlderThan() clears stale anime
  • The @Relation join in AnimeWithFavorite still works across refreshes
// Favorites survive cache clears because they're a separate table
@Query("DELETE FROM anime_cache WHERE cachedAt < :before")
suspend fun deleteOlderThan(before: Long)  // ← doesn't touch anime_favorites

@Query("SELECT * FROM anime_favorites ORDER BY addedAt DESC")
fun observeFavorites(): Flow<List<FavoriteEntity>>  // ← always intact

The confirm-to-remove interaction in the favorites grid uses a local showConfirm state — no dialog needed, just a composable overlay:

var showConfirm by remember { mutableStateOf(false) }

AnimatedVisibility(visible = showConfirm) {
    Box(Modifier.fillMaxSize().background(Color(0xEE0E0E14))) {
        // Cancel / Remove buttons
    }
}

Key Takeaways

Room lessons from this project:

  • Use Flow for live-updating queries — not suspend + one-shot reads
  • @TypeConverters solves complex field types — register at @Database level
  • @Relation + @Transaction for joined queries across tables
  • @Index on frequently queried columns (malIdcategory) for performance
  • cachedAt: Long on every cached entity — you need it for TTL logic

Architecture lessons:

  • The interface (IAnimeRepository) is the boundary — define it in domain, implement in data
  • Use cases enforce the single-responsibility principle and keep ViewModels lean
  • AnimeResult belongs in domain — not in the layer that produces it
  • factory {} for use cases in Koin, single {} for the repository

UI lessons:

  • Show the user where their data came from — cache vs network transparency builds confidence
  • StateFlow + collectAsState() is the idiomatic Compose pattern for ViewModel → UI
  • SharingStarted.WhileSubscribed(5000) stops the flow 5 seconds after the last subscriber — proper lifecycle management

Source Code

https://github.com/rishira-n/CacheKage

The complete project is available with 27 Kotlin files covering the full stack — from Room entity to Compose UI. Every architectural decision in this article is reflected in the code.

Built with: Kotlin · Jetpack Compose · Room 2.7.1 · Retrofit 2.11 · Koin 3.5 · Jikan API v4


📚 References

Room Persistence Library

https://developer.android.com/training/data-storage/room

https://rushira.in/https-rushira-in-room/