Goal
Take a real-world "Activity-does-everything" anti-pattern and refactor it step-by-step into clean MVVM with a Repository, Use Cases, StateFlow, and unit tests.
Estimated time: 60 minutes.
Starting Point: The Anti-Pattern
// UserListActivity.kt — before refactor
class UserListActivity : AppCompatActivity() {
private val retrofit = Retrofit.Builder()
.baseUrl("https://jsonplaceholder.typicode.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(JsonPlaceholderApi::class.java)
private val adapter = UserAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_user_list)
val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
val progressBar = findViewById<ProgressBar>(R.id.progressBar)
val errorText = findViewById<TextView>(R.id.errorText)
recyclerView.adapter = adapter
// Network call directly in Activity
lifecycleScope.launch {
progressBar.isVisible = true
errorText.isVisible = false
try {
val users = retrofit.getUsers()
adapter.submitList(users.map { dto ->
// Mapping inline in the Activity!
UserItem(id = dto.id.toString(), name = dto.name, email = dto.email)
})
} catch (e: Exception) {
errorText.text = "Failed to load: ${e.message}"
errorText.isVisible = true
} finally {
progressBar.isVisible = false
}
}
}
}
Problems:
- Retrofit construction in Activity — cannot swap for tests
- Network call in
onCreate— survives rotation issues - Mapping in Activity — business logic in wrong layer
- No error recovery, no retry
- Untestable without UI
Step 1: Extract the Data Models
Create separate models for network, domain, and UI layers.
// data/remote/dto/UserDto.kt
data class UserDto(
@SerializedName("id") val id: Int,
@SerializedName("name") val name: String,
@SerializedName("email") val email: String,
@SerializedName("username") val username: String
)
// domain/model/User.kt — pure Kotlin, no framework imports
data class User(
val id: String,
val name: String,
val email: String
)
// ui/model/UserUiModel.kt
data class UserUiModel(
val id: String,
val displayName: String,
val emailLabel: String
)
// Mapping extensions
fun UserDto.toDomain() = User(
id = id.toString(),
name = name,
email = email
)
fun User.toUiModel() = UserUiModel(
id = id,
displayName = name,
emailLabel = email.lowercase()
)
Step 2: Create the Remote Data Source
// data/remote/UserRemoteDataSource.kt
interface UserRemoteDataSource {
suspend fun fetchUsers(): List<UserDto>
}
class UserRemoteDataSourceImpl @Inject constructor(
private val api: JsonPlaceholderApi
) : UserRemoteDataSource {
override suspend fun fetchUsers(): List<UserDto> = api.getUsers()
}
// data/remote/JsonPlaceholderApi.kt
interface JsonPlaceholderApi {
@GET("users")
suspend fun getUsers(): List<UserDto>
}
Step 3: Create the Repository
// domain/repository/UserRepository.kt
interface UserRepository {
suspend fun getUsers(): Result<List<User>>
}
// data/repository/UserRepositoryImpl.kt
class UserRepositoryImpl @Inject constructor(
private val remoteDataSource: UserRemoteDataSource
) : UserRepository {
override suspend fun getUsers(): Result<List<User>> = runCatching {
remoteDataSource.fetchUsers().map { it.toDomain() }
}
}
Step 4: Create the Use Case
// domain/usecase/GetUsersUseCase.kt
class GetUsersUseCase @Inject constructor(
private val userRepository: UserRepository
) {
suspend operator fun invoke(): Result<List<User>> = userRepository.getUsers()
}
In this case the use case is thin, but it provides a boundary for adding future logic (filtering, sorting, analytics tracking) without touching the ViewModel or Repository.
Step 5: Create the ViewModel with StateFlow
// ui/userlist/UserListUiState.kt
data class UserListUiState(
val isLoading: Boolean = false,
val users: List<UserUiModel> = emptyList(),
val error: String? = null
) {
val isEmpty: Boolean get() = !isLoading && users.isEmpty() && error == null
}
// ui/userlist/UserListViewModel.kt
@HiltViewModel
class UserListViewModel @Inject constructor(
private val getUsersUseCase: GetUsersUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(UserListUiState())
val uiState: StateFlow<UserListUiState> = _uiState.asStateFlow()
init {
loadUsers()
}
fun retry() = loadUsers()
private fun loadUsers() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
getUsersUseCase()
.onSuccess { users ->
_uiState.update {
it.copy(
isLoading = false,
users = users.map { user -> user.toUiModel() }
)
}
}
.onFailure { error ->
_uiState.update {
it.copy(isLoading = false, error = error.message ?: "Unknown error")
}
}
}
}
}
Step 6: Refactor the Activity to Observe
// ui/userlist/UserListActivity.kt — after refactor
@AndroidEntryPoint
class UserListActivity : AppCompatActivity() {
private val viewModel: UserListViewModel by viewModels()
private lateinit var binding: ActivityUserListBinding
private val adapter = UserAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityUserListBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.recyclerView.adapter = adapter
binding.btnRetry.setOnClickListener { viewModel.retry() }
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state -> render(state) }
}
}
}
private fun render(state: UserListUiState) {
binding.progressBar.isVisible = state.isLoading
binding.errorGroup.isVisible = state.error != null
binding.errorText.text = state.error
binding.emptyState.isVisible = state.isEmpty
adapter.submitList(state.users)
}
}
The Activity is now a pure view — it renders state and dispatches actions. No business logic.
Step 7: Hilt Module Setup
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit = Retrofit.Builder()
.baseUrl("https://jsonplaceholder.typicode.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
@Provides
@Singleton
fun provideApi(retrofit: Retrofit): JsonPlaceholderApi =
retrofit.create(JsonPlaceholderApi::class.java)
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindUserRemoteDataSource(
impl: UserRemoteDataSourceImpl
): UserRemoteDataSource
@Binds
abstract fun bindUserRepository(
impl: UserRepositoryImpl
): UserRepository
}
Step 8: Unit Tests
Test the Repository
class UserRepositoryTest {
private val mockRemoteDataSource: UserRemoteDataSource = mockk()
private val repository = UserRepositoryImpl(mockRemoteDataSource)
@Test
fun `getUsers maps DTOs to domain models`() = runTest {
coEvery { mockRemoteDataSource.fetchUsers() } returns listOf(
UserDto(id = 1, name = "Alice", email = "alice@example.com", username = "alice")
)
val result = repository.getUsers()
assertTrue(result.isSuccess)
assertEquals("1", result.getOrThrow().first().id)
assertEquals("Alice", result.getOrThrow().first().name)
}
@Test
fun `getUsers returns failure on network error`() = runTest {
coEvery { mockRemoteDataSource.fetchUsers() } throws IOException("No internet")
val result = repository.getUsers()
assertTrue(result.isFailure)
}
}
Test the ViewModel
@OptIn(ExperimentalCoroutinesApi::class)
class UserListViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule() // Sets Dispatchers.Main to TestCoroutineScheduler
private val mockUseCase: GetUsersUseCase = mockk()
private lateinit var viewModel: UserListViewModel
@Test
fun `init triggers loading then success`() = runTest {
coEvery { mockUseCase() } returns Result.success(listOf(
User(id = "1", name = "Alice", email = "alice@example.com")
))
viewModel = UserListViewModel(mockUseCase)
val finalState = viewModel.uiState.value
assertFalse(finalState.isLoading)
assertEquals(1, finalState.users.size)
assertEquals("Alice", finalState.users.first().displayName)
}
@Test
fun `retry reloads users`() = runTest {
coEvery { mockUseCase() } returns Result.failure(IOException("timeout"))
viewModel = UserListViewModel(mockUseCase)
coEvery { mockUseCase() } returns Result.success(listOf(
User(id = "1", name = "Alice", email = "alice@example.com")
))
viewModel.retry()
assertNull(viewModel.uiState.value.error)
assertEquals(1, viewModel.uiState.value.users.size)
}
}
Before vs After Comparison
| Concern | Before | After |
|---|---|---|
| Network construction | In Activity | Hilt module |
| Data fetching | onCreate | ViewModel + UseCase |
| Model mapping | Inline in Activity | Extension functions in data layer |
| State | Ad-hoc flags | UserListUiState data class |
| Error handling | catch in Activity | Repository Result, ViewModel update |
| Testability | Impossible without UI | JVM unit tests for all layers |
| Rotation | Re-triggers network call | ViewModel survives, StateFlow replays |
Key Takeaways
| Concept | Summary |
|---|---|
| Activity's role | Only render state and dispatch actions |
| Repository | Abstracts data source; returns Result<T> |
| UseCase | Single operation boundary; operator fun invoke() |
| ViewModel | Holds StateFlow; survives rotation; no Android imports except SavedStateHandle |
repeatOnLifecycle | Stops collecting when in background; resumes when STARTED |
| Testing | Each layer testable independently with fakes/mocks |