Separate presentation, domain, and data layers.
// domain/UseCase.kt
fun interface UseCase<in P, R> { suspend operator fun invoke(params: P): R }
// domain/TodosRepo.kt
interface TodosRepo { suspend fun list(): List<String> }
// data/HttpTodosRepo.kt
class HttpTodosRepo @Inject constructor(private val api: Api): TodosRepo {
override suspend fun list() = api.getTodos().map { it.name }
}
Domain → Data (Retrofit mapper) → DI (Hilt) → VM → Compose screen.
// domain/Todo.kt
data class Todo(val id: String, val title: String)
interface TodosRepo { suspend fun list(): List<Todo> }
class GetTodos(private val repo: TodosRepo) { suspend operator fun invoke(): List<Todo> = repo.list() }
// data/Api.kt
data class TodoDto(val id: Int, val title: String, val completed: Boolean)
interface Api { @GET("/todos") suspend fun getTodos(): List<TodoDto> }
fun TodoDto.toDomain() = Todo(id = id.toString(), title = title)
// data/HttpTodosRepo.kt
class HttpTodosRepo @Inject constructor(private val api: Api): TodosRepo {
override suspend fun list(): List<Todo> = api.getTodos().map { it.toDomain() }
}
// di/Modules.kt
@Module @InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton fun retrofit(): Retrofit = Retrofit.Builder()
.baseUrl("https://jsonplaceholder.typicode.com")
.addConverterFactory(MoshiConverterFactory.create())
.build()
@Provides @Singleton fun api(retrofit: Retrofit): Api = retrofit.create(Api::class.java)
}
@Module @InstallIn(SingletonComponent::class)
object RepoModule {
@Provides fun todosRepo(api: Api): TodosRepo = HttpTodosRepo(api)
}
// ui/TodoViewModel.kt
@HiltViewModel
class TodoViewModel @Inject constructor(private val getTodos: GetTodos): ViewModel() {
sealed interface State { data object Loading: State; data class Data(val items: List<Todo>): State; data class Error(val msg: String): State }
private val _state = MutableStateFlow<State>(State.Loading)
val state: StateFlow<State> = _state.asStateFlow()
init { load() }
fun load() = viewModelScope.launch { runCatching { getTodos() }
.onSuccess { _state.value = State.Data(it) }
.onFailure { _state.value = State.Error(it.message ?: "Oops") } }
}
// ui/TodoScreen.kt
@Composable
fun TodoScreen(vm: TodoViewModel = hiltViewModel()) {
val s by vm.state.collectAsStateWithLifecycle()
when (val st = s) {
is TodoViewModel.State.Loading -> CircularProgressIndicator()
is TodoViewModel.State.Error -> Column { Text(st.msg); Button({ vm.load() }) { Text("Retry") } }
is TodoViewModel.State.Data -> LazyColumn { items(st.items) { Text(it.title) } }
}
}
Notes
Paste into Android Studio project (see sandboxes/android-compose):