Skip to main content

Overview

The android-data-layer skill provides guidance on implementing a robust data layer using the Repository pattern. It focuses on offline-first architecture with Room for local persistence, Retrofit for remote data, and proper synchronization strategies. When to use this skill:
  • Implementing data persistence with Room
  • Fetching data from REST APIs with Retrofit
  • Building offline-first applications
  • Setting up repository patterns
  • Implementing data synchronization strategies
  • Handling network errors gracefully

Repository Pattern

The Repository acts as the Single Source of Truth (SSOT) in your application, deciding whether to return cached data or fetch fresh data from the network.

Basic Implementation

class NewsRepository @Inject constructor(
    private val newsDao: NewsDao,
    private val newsApi: NewsApi
) {
    // Expose data from Local DB as the source of truth
    val newsStream: Flow<List<News>> = newsDao.getAllNews()

    // Sync operation
    suspend fun refreshNews() {
        val remoteNews = newsApi.fetchLatest()
        newsDao.insertAll(remoteNews)
    }
}

Key Principles

Single Source of Truth

The repository is the single source of truth for data access

Intelligent Caching

Decides when to return cached data vs. fetching fresh data

Data Coordination

Coordinates data from multiple sources (local DB, network, etc.)

Main-Safe

All repository functions should be main-safe (suspend functions)

Local Persistence with Room

Room is used as the primary cache and offline storage mechanism.

Entity Definition

@Entity(tableName = "news")
data class NewsEntity(
    @PrimaryKey val id: String,
    val title: String,
    val content: String,
    val publishedAt: Long,
    val imageUrl: String?
)

DAO with Flow

@Dao
interface NewsDao {
    @Query("SELECT * FROM news ORDER BY publishedAt DESC")
    fun getAllNews(): Flow<List<NewsEntity>>
    
    @Query("SELECT * FROM news WHERE id = :newsId")
    fun getNewsById(newsId: String): Flow<NewsEntity?>
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(news: List<NewsEntity>)
    
    @Query("DELETE FROM news")
    suspend fun deleteAll()
}
Return Flow<T> from DAOs for observable data. This allows the UI to automatically update when the database changes.

Database Setup

@Database(
    entities = [NewsEntity::class],
    version = 1,
    exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun newsDao(): NewsDao
}

// Hilt Module
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
        return Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "app-database"
        ).build()
    }
    
    @Provides
    fun provideNewsDao(database: AppDatabase): NewsDao {
        return database.newsDao()
    }
}

Remote Data with Retrofit

Retrofit is used for fetching data from backend APIs.

API Interface

interface NewsApi {
    @GET("news/latest")
    suspend fun fetchLatest(): List<NewsDto>
    
    @GET("news/{id}")
    suspend fun fetchNewsById(@Path("id") id: String): NewsDto
    
    @POST("news/{id}/like")
    suspend fun likeNews(@Path("id") id: String): Response<Unit>
}
  • Use suspend functions in Retrofit interfaces for coroutine support
  • Define data transfer objects (DTOs) separate from domain models
  • Use appropriate HTTP method annotations (@GET, @POST, etc.)

Retrofit Setup

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
    
    @Provides
    @Singleton
    fun provideNewsApi(retrofit: Retrofit): NewsApi {
        return retrofit.create(NewsApi::class.java)
    }
}

Error Handling

Always wrap network calls in try-catch blocks or use a Result wrapper to handle exceptions gracefully (no internet, 404, timeout, etc.).
sealed class Result<out T> {
    data class Success<T>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()
}

suspend fun <T> safeApiCall(apiCall: suspend () -> T): Result<T> {
    return try {
        Result.Success(apiCall())
    } catch (e: Exception) {
        Result.Error(e)
    }
}

// Usage in Repository
suspend fun refreshNews(): Result<Unit> {
    return safeApiCall {
        val remoteNews = newsApi.fetchLatest()
        newsDao.insertAll(remoteNews.map { it.toEntity() })
    }
}

Synchronization Strategies

Stale-While-Revalidate (Read)

Show local data immediately while fetching fresh data in the background.
class NewsRepository @Inject constructor(
    private val newsDao: NewsDao,
    private val newsApi: NewsApi
) {
    val newsStream: Flow<List<News>> = newsDao.getAllNews()
        .map { entities -> entities.map { it.toDomain() } }
    
    suspend fun refreshNews() {
        try {
            // Fetch from network
            val remoteNews = newsApi.fetchLatest()
            
            // Update local cache
            newsDao.insertAll(remoteNews.map { it.toEntity() })
        } catch (e: Exception) {
            // Handle error - local data still available
            Log.e("NewsRepository", "Failed to refresh", e)
        }
    }
}
@HiltViewModel
class NewsViewModel @Inject constructor(
    private val repository: NewsRepository
) : ViewModel() {
    
    // Collect local data immediately
    val news = repository.newsStream
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )
    
    init {
        // Trigger background refresh
        refreshNews()
    }
    
    fun refreshNews() {
        viewModelScope.launch {
            repository.refreshNews()
        }
    }
}

Outbox Pattern (Write)

For advanced offline-first writes: save changes locally immediately, mark as “unsynced”, and use WorkManager to push changes to the server.
@Entity(tableName = "news_outbox")
data class NewsOutboxEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val newsId: String,
    val action: String, // "LIKE", "COMMENT", etc.
    val payload: String,
    val isSynced: Boolean = false,
    val createdAt: Long = System.currentTimeMillis()
)

@Dao
interface NewsOutboxDao {
    @Query("SELECT * FROM news_outbox WHERE isSynced = 0")
    suspend fun getUnsyncedItems(): List<NewsOutboxEntity>
    
    @Insert
    suspend fun insert(item: NewsOutboxEntity)
    
    @Query("UPDATE news_outbox SET isSynced = 1 WHERE id = :id")
    suspend fun markAsSynced(id: String)
}

class NewsRepository @Inject constructor(
    private val newsDao: NewsDao,
    private val newsOutboxDao: NewsOutboxDao,
    private val newsApi: NewsApi
) {
    suspend fun likeNews(newsId: String) {
        // 1. Apply optimistic update locally
        newsDao.updateLikeStatus(newsId, liked = true)
        
        // 2. Add to outbox
        newsOutboxDao.insert(
            NewsOutboxEntity(
                newsId = newsId,
                action = "LIKE",
                payload = ""
            )
        )
        
        // 3. WorkManager will sync in background
    }
}
Use WorkManager for reliable background synchronization. It handles retry logic, network constraints, and battery optimization automatically.

Dependency Injection

Bind Repository interfaces to implementations using Hilt.
// Interface
interface NewsRepository {
    val newsStream: Flow<List<News>>
    suspend fun refreshNews()
}

// Implementation
class OfflineFirstNewsRepository @Inject constructor(
    private val newsDao: NewsDao,
    private val newsApi: NewsApi
) : NewsRepository {
    override val newsStream: Flow<List<News>> = 
        newsDao.getAllNews().map { entities -> 
            entities.map { it.toDomain() }
        }
    
    override suspend fun refreshNews() {
        val remoteNews = newsApi.fetchLatest()
        newsDao.insertAll(remoteNews.map { it.toEntity() })
    }
}

// Hilt Module
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    abstract fun bindNewsRepository(
        impl: OfflineFirstNewsRepository
    ): NewsRepository
}
  • Use @Binds in abstract classes for cleaner interface binding
  • Define repository interfaces in the domain layer
  • Implement repositories in the data layer
  • Inject repository interfaces in ViewModels and UseCases

Best Practices

Local as Truth

Local database is the single source of truth

Offline-First

App works offline with cached data

Background Sync

Sync data in the background without blocking UI

Error Handling

Gracefully handle network errors with Result/try-catch

Flow for Observability

Use Flow from DAOs for reactive updates

Main-Safe Operations

All repository functions are main-safe suspend functions

Complete Example

Here’s a complete offline-first repository implementation:
class OfflineFirstNewsRepository @Inject constructor(
    private val newsDao: NewsDao,
    private val newsApi: NewsApi,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : NewsRepository {
    
    override val newsStream: Flow<List<News>> = newsDao.getAllNews()
        .map { entities -> entities.map { it.toDomain() } }
        .flowOn(ioDispatcher)
    
    override suspend fun refreshNews(): Result<Unit> = withContext(ioDispatcher) {
        try {
            // Fetch from API
            val remoteNews = newsApi.fetchLatest()
            
            // Transform and save
            val entities = remoteNews.map { it.toEntity() }
            newsDao.insertAll(entities)
            
            Result.Success(Unit)
        } catch (e: IOException) {
            Result.Error(e)
        } catch (e: HttpException) {
            Result.Error(e)
        }
    }
    
    override suspend fun getNewsById(id: String): Flow<News?> {
        return newsDao.getNewsById(id)
            .map { it?.toDomain() }
            .flowOn(ioDispatcher)
    }
}

Architecture

Understand how the data layer fits into the overall architecture

ViewModel

Learn how ViewModels consume data from repositories

Build docs developers (and LLMs) love