Purpose: Provides repository implementations that coordinate between local and remote data sources, implementing the single source of truth pattern.
The data module is the central data management layer that implements the Repository Pattern.
It coordinates between local (Room, DataStore) and remote (Network, Firebase) data sources to
provide a unified, reactive API for the UI layer.
- Single source of truth: Local database is always the source of truth
- Offline-first: UI always reads from local database
- Background sync: Network data updates local database
- Reactive: Exposes data as Flow for automatic UI updates
This template intentionally uses a two-layer architecture (UI + Data):
- NO domain layer by design
- ViewModels call repositories directly
- Reduces complexity and boilerplate
- Sufficient for most applications
Network/Firebase → Repository → Local Database → Flow → ViewModel → UI
↓
Sync Logic
Use data module for:
- Implementing repository interfaces
- Coordinating local and remote data sources
- Offline-first data management
- Data transformation (DTO ↔ Entity ↔ Domain Model)
- Caching strategies
Don't use data module for:
- UI logic (use feature modules)
- Direct database access (use repositories)
- Business logic without data access (consider if you need a domain layer)
interface UserRepository {
// Observe data (reactive)
fun observeUsers(): Flow<List<User>>
fun observeUserById(id: String): Flow<User?>
// One-shot operations
suspend fun syncUsers(): Result<Unit>
suspend fun createUser(user: User): Result<Unit>
suspend fun updateUser(user: User): Result<Unit>
suspend fun deleteUser(id: String): Result<Unit>
}class UserRepositoryImpl @Inject constructor(
private val localDataSource: UserLocalDataSource,
private val networkDataSource: UserNetworkDataSource,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : UserRepository {
// UI observes local database (single source of truth)
override fun observeUsers(): Flow<List<User>> =
localDataSource.observeUsers()
.map { entities -> entities.map { it.toDomain() } }
override fun observeUserById(id: String): Flow<User?> =
localDataSource.observeUserById(id)
.map { it?.toDomain() }
// Sync from network to local database
override suspend fun syncUsers(): Result<Unit> = suspendRunCatching {
val networkUsers = networkDataSource.getUsers()
localDataSource.saveUsers(
networkUsers.map { dto ->
dto.toEntity().copy(lastSynced = System.currentTimeMillis())
}
)
}
// Create local, then sync
override suspend fun createUser(user: User): Result<Unit> = suspendRunCatching {
val entity = user.toEntity().copy(
syncAction = SyncAction.CREATE,
lastUpdated = System.currentTimeMillis()
)
localDataSource.saveUser(entity)
// SyncWorker will push to server
}
override suspend fun updateUser(user: User): Result<Unit> = suspendRunCatching {
val entity = user.toEntity().copy(
syncAction = SyncAction.UPDATE,
lastUpdated = System.currentTimeMillis()
)
localDataSource.saveUser(entity)
}
override suspend fun deleteUser(id: String): Result<Unit> = suspendRunCatching {
localDataSource.markAsDeleted(id)
}
}class UserRepositoryImpl @Inject constructor(
private val localDataSource: UserLocalDataSource,
private val networkDataSource: UserNetworkDataSource
) : UserRepository {
override fun observeUsers(): Flow<Resource<List<User>>> =
networkBoundResource(
query = {
localDataSource.observeUsers()
.map { entities -> entities.map { it.toDomain() } }
},
fetch = {
networkDataSource.getUsers()
},
saveFetchResult = { dtos ->
localDataSource.saveUsers(dtos.map { it.toEntity() })
},
shouldFetch = { users ->
// Fetch if data is stale
users.isEmpty() || isDataStale()
}
)
}// DTO (from network) → Entity (Room)
fun UserDto.toEntity(): UserEntity = UserEntity(
id = id,
name = name,
email = email,
lastUpdated = System.currentTimeMillis(),
syncAction = SyncAction.NONE
)
// Entity → Domain Model
fun UserEntity.toDomain(): User = User(
id = id,
name = name,
email = email
)
// Domain Model → Entity
fun User.toEntity(): UserEntity = UserEntity(
id = id,
name = name,
email = email
)
// Domain Model → DTO
fun User.toDto(): UserDto = UserDto(
id = id,
name = name,
email = email
)graph TD
A[data] --> B[core:android]
A --> C[core:network]
A --> D[core:preferences]
A --> E[core:room]
A --> F[firebase:auth]
A --> G[firebase:firestore]
subgraph "Core"
B
C
D
E
end
subgraph "Firebase"
F
G
end
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(
impl: UserRepositoryImpl
): UserRepository
}For detailed API documentation, see the Dokka-generated API reference.
- Data Flow Guide - Comprehensive data flow patterns and examples
- Adding a Feature Guide - Step-by-step guide with repository implementation example
- Quick Reference Guide - Repository patterns cheat sheet
- Architecture Overview - Two-layer architecture explained
- Core Room Module - Local data source patterns
- Core Network Module - Remote data source patterns
class RemoteOnlyRepositoryImpl @Inject constructor(
private val networkDataSource: NetworkDataSource
) : RemoteOnlyRepository {
override suspend fun getData(): Result<Data> = suspendRunCatching {
networkDataSource.getData()
}
}class LocalOnlyRepositoryImpl @Inject constructor(
private val localDataSource: LocalDataSource
) : LocalOnlyRepository {
override fun observeData(): Flow<List<Data>> =
localDataSource.observeData()
.map { entities -> entities.map { it.toDomain() } }
}class OfflineFirstRepositoryImpl @Inject constructor(
private val localDataSource: LocalDataSource,
private val networkDataSource: NetworkDataSource
) : OfflineFirstRepository {
// Always observe local
override fun observeData(): Flow<List<Data>> =
localDataSource.observeData()
.map { entities -> entities.map { it.toDomain() } }
// Manual refresh
override suspend fun refresh(): Result<Unit> = suspendRunCatching {
val networkData = networkDataSource.getData()
localDataSource.saveData(networkData.map { it.toEntity() })
}
}See example above using networkBoundResource helper.
This template uses a layered error handling approach with Result<T> and centralized error management.
All repository operations use suspendRunCatching to wrap errors in Result<T>:
override suspend fun createPost(post: Post): Result<Unit> {
return suspendRunCatching {
val userId = preferencesDataSource.getUserIdOrThrow()
// This can throw exceptions
localDataSource.upsertPost(
post.toEntity().copy(
userId = userId,
lastUpdated = System.currentTimeMillis(),
needsSync = true,
syncAction = SyncAction.UPSERT
)
)
// This can also throw
syncManager.requestSync()
}
}Important
Always use suspendRunCatching in repositories. Never let exceptions bubble up to ViewModels.
Use updateStateWith or updateWith for automatic error handling:
fun createPost(title: String, content: String) {
val newPost = Post(title = title, content = content)
// Errors are automatically caught and set in UiState.error
_uiState.updateWith {
postsRepository.createPost(newPost)
}
}The updateStateWith and updateWith functions automatically:
- Set
loading = truebefore the operation - Set
loading = falseafter completion - Capture exceptions and set
errorfield in UiState - Transform successful results into new state
StatefulComposable automatically displays errors via snackbar:
@Composable
fun PostsRoute(
onShowSnackbar: suspend (String, SnackbarAction, Throwable?) -> Boolean,
viewModel: PostsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
StatefulComposable(
state = uiState,
onShowSnackbar = onShowSnackbar // Errors are shown automatically
) { screenData ->
PostsScreen(
posts = screenData.posts,
onCreatePost = viewModel::createPost
)
}
}When an error occurs, StatefulComposable:
- Displays a snackbar with the error message
- Provides an optional "Retry" action
- Logs the error for debugging
- Maintains the current UI state (no crash)
Handle specific network errors in repository:
override suspend fun syncPosts(): Result<Unit> {
return suspendRunCatching {
// Check network availability first
networkUtils.getCurrentState().first().let { state ->
if (state != NetworkState.CONNECTED) {
throw IOException("No network connection available")
}
}
// Proceed with sync
val userId = preferencesDataSource.getUserIdOrThrow()
// ... sync logic
}.onFailure { error ->
when (error) {
is IOException -> {
// Network error - data will sync later
Timber.w(error, "Network error during sync, will retry later")
}
is HttpException -> {
// Server error
Timber.e(error, "Server error during sync: ${error.code()}")
}
else -> {
// Unknown error
Timber.e(error, "Unknown error during sync")
}
}
}
}Network errors from Retrofit are converted to appropriate exceptions:
| Error Code | Exception Type | Meaning | Typical Action |
|---|---|---|---|
| 401/403 | HttpException |
Authentication failure | Sign user out, refresh token |
| 404 | HttpException |
Resource not found | Show "not found" message |
| 500 | HttpException |
Server error | Retry with backoff |
| Network failure | IOException |
No connectivity | Use cached data, retry later |
Repository Operation
↓
suspendRunCatching { ... }
↓
[Success or Failure]
↓
Result<T>
↓
ViewModel
↓
updateStateWith/updateWith
↓
[Auto-handle Result]
↓
UiState (data or error)
↓
StatefulComposable
↓
[Show content or error snackbar]
- Always use
suspendRunCatchingfor error handling in repositories - Return Flow for observable data, Result for one-shot operations
- Keep repositories focused on data coordination (no business logic)
- Use injected dispatchers from core:android
- Implement mapper functions for clean data transformation
- Prefer local database as source of truth for offline-first
- Update sync metadata when modifying local data
- Return domain models from repositories (hide DTOs and Entities)
- Never let exceptions escape repositories - wrap all operations in
suspendRunCatching - Use specific error types when possible (IOException for network, IllegalStateException for invalid states)
This template follows a pragmatic simplicity approach:
Two-layer architecture (UI + Data):
- ViewModels call repositories directly
- Repositories return domain models (simple data classes)
- Reduces boilerplate and indirection
- Easier to understand and maintain
When to add a domain layer:
- Complex business logic that doesn't fit in repositories
- Multiple UI representations of the same data
- Shared business rules across features
- Heavy data transformation logic
For most applications, two layers are sufficient.
This module is used by all feature modules that need data access:
dependencies {
implementation(project(":data"))
}