$npx -y skills add rcosteira79/android-skills --skill android-data-layerUse when implementing the data layer in Android or KMP — the Repository pattern's layered error-propagation model (repository as the error boundary, sealed DataError, Result placement with and without a domain layer) and Room in commonMain (@ConstructedBy, BundledSQLiteDriver, pe
| 1 | # Android Data Layer |
| 2 | |
| 3 | The data layer coordinates data from multiple sources; its public API is repository interfaces, and its internals (DAOs, API services, DTOs) never leak upward. This skill is the canonical home for the **error-propagation model**, plus the KMP-Room setup. **Related:** `android-skills:android-retrofit` (Retrofit service/OkHttp/Hilt wiring), `android-skills:android-dev` (overall app architecture). |
| 4 | |
| 5 | ## Error propagation — the layered model |
| 6 | |
| 7 | The goal: data-layer errors (`IOException` / `HttpException` / `SQLiteException`) must never reach the ViewModel. Map them to a domain error type at the repository boundary. If the project already has an error convention, match it; otherwise a sealed `DataError` hierarchy is a reasonable default: |
| 8 | |
| 9 | ```kotlin |
| 10 | sealed class DataError(message: String, cause: Throwable? = null) : Exception(message, cause) { |
| 11 | class Network(cause: Throwable) : DataError("Network error", cause) |
| 12 | class Server(val code: Int, message: String?) : DataError("Server error $code: $message") |
| 13 | class Local(cause: Throwable) : DataError("Local storage error", cause) |
| 14 | } |
| 15 | |
| 16 | // repository = error boundary: catch platform exceptions, return Result<T> (no domain layer) or throw DataError (with one) |
| 17 | suspend fun refreshNews(): Result<Unit> = withContext(io) { |
| 18 | try { newsDao.insertAll(newsApi.fetchLatest().map(NewsDto::toEntity)); Result.success(Unit) } |
| 19 | catch (e: IOException) { Result.failure(DataError.Network(e)) } |
| 20 | catch (e: HttpException) { Result.failure(DataError.Server(e.code(), e.message())) } |
| 21 | } |
| 22 | ``` |
| 23 | |
| 24 | The boundary moves outward by one layer when a domain layer exists: |
| 25 | |
| 26 | 1. **Data sources** throw platform/library exceptions (`IOException`, `HttpException`, `SQLiteException`). |
| 27 | 2. **Repository** is the error boundary — catch those and remap to `DataError`; never let raw platform types leak. *Without a domain layer (simple MVVM):* the repository returns `Result<T>` directly and the ViewModel handles it without knowing about platform exceptions. |
| 28 | 3. **Use cases** (when present) are the `Result` boundary — the repository instead *throws* `DataError`, and the use case catches it and returns `Result<T>` with a domain-specific error model. Use cases never catch platform exception types. |
| 29 | 4. **ViewModels** handle `Result<T>` and map it to UI state (explicit loading / success / error). |
| 30 | |
| 31 | (Default to the `Entity` suffix — `ArticleEntity` — for Room types; match an existing convention such as a `Cached` prefix if the project already uses one.) |
| 32 | |
| 33 | ## Room in KMP (`commonMain`) |
| 34 | |
| 35 | Room has been KMP-stable since 2.7.0. The shared setup differs from the Android-only setup in three places: |
| 36 | |
| 37 | 1. **`@ConstructedBy(...)`** on the `@Database`, paired with an `expect object` Room generates per-platform `actual`s for. |
| 38 | 2. **`BundledSQLiteDriver`** (from `androidx.sqlite:sqlite-bundled`) — pins the same SQLite version across Android/iOS/JVM/web (Android's system SQLite drifts between API levels and devices). |
| 39 | 3. **`setQueryCoroutineContext(Dispatchers.IO)`** — Android Room defaults this; KMP doesn't. |
| 40 | |
| 41 | ```kotlin |
| 42 | // commonMain |
| 43 | @Database(entities = [ArticleEntity::class], version = 1, exportSchema = true) |
| 44 | @ConstructedBy(AppDatabaseConstructor::class) |
| 45 | abstract class AppDatabase : RoomDatabase() { abstract fun articleDao(): ArticleDao } |
| 46 | |
| 47 | @Suppress("KotlinNoActualForExpect") |
| 48 | expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> |
| 49 | |
| 50 | fun getRoomDatabase(builder: RoomDatabase.Builder<AppDatabase>): AppDatabase = |
| 51 | builder.setDriver(BundledSQLiteDriver()).setQueryCoroutineContext(Dispatchers.IO).build() |
| 52 | ``` |
| 53 | |
| 54 | Each platform provides its own builder — Android `Room.databaseBuilder(context, AppDatabase::class.java, "app.db")`; iOS/JVM `Room.databaseBuilder<AppDatabase>(databasePath = …)`. **KSP must be wired per target** (`ksp(libs.androidx.room.compiler)` is Android-only): |
| 55 | |
| 56 | ```kotlin |
| 57 | dependencies { |
| 58 | add("kspAndroid", libs.androidx.room.compiler) |
| 59 | add("kspIosArm64", libs.androidx.room.compiler) |
| 60 | add("kspIosSimulatorArm64", libs.androidx.room.compiler) |
| 61 | // … one per target |
| 62 | } |
| 63 | room { schemaDirectory("$projectDir/schemas") } // export the schema for migration-history tracking |
| 64 | ``` |
| 65 | |
| 66 | For pure-Android projects, skip `@ConstructedBy` and call `Room.databaseBuilder(context, AppDatabase::class.java, "app.db")` directly — Room behaves identically; the KMP setup is opt-in when the data layer must live in `commonMain`. |