androidengineers.Book a session

Architecture Patterns

Exercise: Refactor to MVVM + Use Cases

exercise60 minMedium

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

ConcernBeforeAfter
Network constructionIn ActivityHilt module
Data fetchingonCreateViewModel + UseCase
Model mappingInline in ActivityExtension functions in data layer
StateAd-hoc flagsUserListUiState data class
Error handlingcatch in ActivityRepository Result, ViewModel update
TestabilityImpossible without UIJVM unit tests for all layers
RotationRe-triggers network callViewModel survives, StateFlow replays

Key Takeaways

ConceptSummary
Activity's roleOnly render state and dispatch actions
RepositoryAbstracts data source; returns Result<T>
UseCaseSingle operation boundary; operator fun invoke()
ViewModelHolds StateFlow; survives rotation; no Android imports except SavedStateHandle
repeatOnLifecycleStops collecting when in background; resumes when STARTED
TestingEach layer testable independently with fakes/mocks

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: Refactor to MVVM + Use Cases | Android System Design | Android Engineers