DataSources
DataSources are the foundation of Archer. They represent a source of data with a simple contract.
Types of DataSources
GetDataSource
A GetDataSource is the simplest type - it retrieves data based on a parameter.
interface GetDataSource<in K, out V> {
suspend fun get(key: K): V
}
Creating a GetDataSource
Use the DSL to create a GetDataSource:
val remoteDataSource = getDataSource<Int, String> { param: Int ->
// Your implementation
api.fetchData(param)
}
Example: API Data Source
data class User(val id: Int, val name: String)
val userApiDataSource = getDataSource<Int, User> { userId ->
// Call your API
val response = apiClient.getUser(userId)
// Map to domain model
response.toDomain()
}
StoreDataSource
A StoreDataSource extends GetDataSource with the ability to store data.
interface StoreDataSource<K, V> : GetDataSource<K, V> {
suspend fun get(key: K): V
suspend fun put(key: K, value: V)
}
Built-in Store DataSources
InMemoryDataSource
Simple in-memory cache:
val cache: StoreDataSource<String, User> = InMemoryDataSource()
Custom Store DataSource
Implement your own storage:
class DatabaseDataSource : StoreDataSource<UserId, User> {
override suspend fun get(key: UserId): User {
return database.userDao().getUser(key.value)
?: raise(UserNotFound(key))
}
override suspend fun put(key: UserId, value: User) {
database.userDao().insertUser(value.toEntity())
}
}
DeleteDataSource
For data sources that support deletion:
interface DeleteDataSource<in K> {
suspend fun delete(key: K)
}
Example:
class UserStorageDataSource :
StoreDataSource<UserId, User>,
DeleteDataSource<UserId> {
override suspend fun get(key: UserId): User {
// Fetch from storage
}
override suspend fun put(key: UserId, value: User) {
// Store in storage
}
override suspend fun delete(key: UserId) {
database.userDao().deleteUser(key.value)
}
}
Data Source Composition
DataSources can be composed to create more complex behaviors.
Mapping
Transform data from one type to another:
// API returns UserDto, but we want User domain model
val apiDataSource = getDataSource<Int, UserDto> { id ->
api.getUser(id)
}
val domainDataSource = apiDataSource.map { dto ->
dto.toDomain()
}
Validation
Add validation to your data sources:
val validatedDataSource = dataSource.validate { user ->
if (user.name.isBlank()) {
raise(InvalidUserData("Name cannot be blank"))
}
}
Error Handling
DataSources use Arrow's raise mechanism for error handling:
val dataSource = getDataSource<UserId, User> { userId ->
val response = api.getUser(userId.value)
when {
response.isSuccessful -> response.body()!!.toDomain()
response.code() == 404 -> raise(UserNotFound(userId))
else -> raise(NetworkError(response.message()))
}
}
Best Practices
1. Keep DataSources Simple
DataSources should focus on data retrieval/storage, not business logic:
// Good: Simple data fetching
val userDataSource = getDataSource<UserId, User> { userId ->
api.getUser(userId.value).toDomain()
}
// Bad: Business logic in DataSource
val userDataSource = getDataSource<UserId, User> { userId ->
val user = api.getUser(userId.value).toDomain()
if (user.isPremium) {
// Don't put business logic here!
loadPremiumFeatures()
}
user
}
2. Handle Mapping at the DataSource Level
// Map API models to domain models in the DataSource
val dataSource = getDataSource<Int, User> { id ->
apiClient.getUser(id).toDomain() // Mapping here
}
3. Use Typed Errors
sealed interface UserError : DomainError {
data class NotFound(val userId: UserId) : UserError
data class InvalidData(val reason: String) : UserError
data class NetworkError(val cause: Throwable) : UserError
}
val dataSource = getDataSource<UserId, User> { userId ->
try {
api.getUser(userId.value).toDomain()
} catch (e: Exception) {
raise(UserError.NetworkError(e))
}
}
Next Steps
- Repositories - Combine DataSources into repositories
- Result Types - Handle results with Ice, Either, or Nullable
- Examples - See practical examples