Data Layer
Architecture
data/
core/ -> BaseDataSource, BaseMapper, ExceptionMapper, DTO base classes
local-data/ -> Room database, DataStore, DAOs
remote-data/ -> Retrofit services, Firebase sources, Repository implementations
BaseDataSource + Strategy Pattern
open class BaseDataSource {
// Firebase Task<T> -> Flow<DataState<T>>
protected fun <T> fetchData(call: suspend () -> Task<T>): Flow<DataState<T>>
// Retrofit Response<BaseDto<T>> -> Flow<DataState<T>>
protected fun <T> fetchRestData(call: suspend () -> Response<BaseDto<T>>): Flow<DataState<T>>
// Paging3 -> Flow<PagingData<T>>
protected fun <Key, Value> fetchPagingData(
config: PagingConfig,
remoteMediator: RemoteMediator<Key, Value>? = null,
pagingSourceFactory: () -> PagingSource<Key, Value>,
): Flow<PagingData<Value>>
// Simple sync wrapper -> Flow<DataState<T>>
protected fun <T> requestData(call: () -> T): Flow<DataState<T>>
}
Strategy Classes (object singletons in data/core/.../strategy/)
| Strategy | Input | Output | Dispatcher |
|---|---|---|---|
FirebaseDataSourceStrategy |
Task<T> |
Flow<DataState<T>> |
Dispatchers.IO |
RetrofitDataSourceStrategy |
Response<BaseDto<T>> |
Flow<DataState<T>> |
Dispatchers.IO |
PagingDataSourceStrategy |
PagingSource |
Flow<PagingData<T>> |
Dispatchers.IO |
Creating a DataSource
class ItemRemoteDataSource @Inject constructor(
private val apiService: ItemApiService,
private val itemMapper: ItemMapper,
) : BaseDataSource() {
// REST API call
fun getItemById(id: String): Flow<DataState<Item>> =
fetchRestData { apiService.getItem(id) }
.map { state ->
when (state) {
is DataState.Success -> DataState.Success(itemMapper.toDomain(state.data))
is DataState.Error -> state
is DataState.Loading -> state
}
}
// Firebase call
fun getItemFromFirebase(id: String): Flow<DataState<ItemDto>> =
fetchData { firestore.collection("items").document(id).get() }
// Paging call
fun getItemsPaged(): Flow<PagingData<Item>> =
fetchPagingData(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = { ItemPagingSource(apiService, itemMapper) },
)
}
Mapper Pattern
// One-way: DTO -> Domain
interface BaseMapper<DTO, DOMAIN> {
fun toDomain(dto: DTO): DOMAIN
fun toDomainList(dtos: List<DTO>): List<DOMAIN> = dtos.map { toDomain(it) }
}
// Two-way: DTO <-> Domain
interface BidirectionalMapper<DTO, DOMAIN> : BaseMapper<DTO, DOMAIN> {
fun toDto(domain: DOMAIN): DTO
fun toDtoList(domains: List<DOMAIN>): List<DTO> = domains.map { toDto(it) }
}
// Auto-generated with Konvert KSP
@Konvert(fromClass = ItemDto::class, toClass = Item::class)
interface ItemMapper : BaseMapper<ItemDto, Item>
// Or manual
class ItemMapper @Inject constructor() : BaseMapper<ItemDto, Item> {
override fun toDomain(dto: ItemDto): Item = Item(id = dto.id, name = dto.name)
}
DTO Structure
// REST API wrapper - RetrofitDataSourceStrategy validates chain:
// HTTP success -> body not null -> success=true -> data not null
data class BaseDto<T>(val success: Boolean, val data: T?, val error: String?)
data class ApiErrorDto(val httpCode: Int?, val error: String?)
Repository Pattern
// Interface in domain/repository/
interface ItemRepository {
fun getItems(): Flow<PagingData<Item>>
fun getItemDetail(id: String): Flow<DataState<Item>>
fun searchItems(keyword: String): Flow<PagingData<Item>>
suspend fun saveItem(id: String): Flow<DataState<Any>>
}
// Implementation in data/remote-data/
class ItemRepositoryImpl @Inject constructor(
private val remoteDataSource: ItemRemoteDataSource,
private val localDataSource: ItemLocalDataSource,
) : ItemRepository {
override fun getItems(): Flow<PagingData<Item>> =
remoteDataSource.getItemsPaged()
override fun getItemDetail(id: String): Flow<DataState<Item>> =
remoteDataSource.getItemById(id)
}
Hilt DI Modules
// Repository Module (data/remote-data/di/)
@Module @InstallIn(SingletonComponent::class)
interface RepositoryModule {
@Binds fun bindItemRepository(impl: ItemRepositoryImpl): ItemRepository
}
// Network Module
@Module @InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun provideRetrofit(): Retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.API_URL)
.addConverterFactory(MoshiConverterFactory.create())
.client(okHttpClient)
.build()
@Provides
fun provideItemApiService(retrofit: Retrofit): ItemApiService =
retrofit.create(ItemApiService::class.java)
}
// Database Module (data/local-data/di/)
@Module @InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides @Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "app_db").build()
@Provides
fun provideItemDao(db: AppDatabase): ItemDao = db.itemDao()
}
Custom PagingSource
class ItemPagingSource(
private val apiService: ItemApiService,
private val mapper: ItemMapper,
) : PagingSource<Int, Item>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
val page = params.key ?: 1
return try {
val response = apiService.getItems(page = page, limit = params.loadSize)
val items = mapper.toDomainList(response.body()?.data ?: emptyList())
LoadResult.Page(
data = items,
prevKey = if (page == 1) null else page - 1,
nextKey = if (items.isEmpty()) null else page + 1,
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Item>): Int? =
state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
}
}
DataState Flow Pattern
All DataSource methods returning Flow<DataState<T>>:
Emit Loading -> Execute operation -> Emit Success/Error
Errors are automatically mapped by ExceptionMapper within each strategy class.