In modern Android development, Clean Architecture and effective state management are essential for building scalable, maintainable, and robust applications. They help developers organize code, separate concerns, and ensure smooth state transitions, ultimately improving app performance, testability, and resilience to future changes. This post will delve into combining Clean Architecture principles with modern state management using StateFlow and Jetpack Compose. We’ll also cover implementing proper error handling and testing.
What we’ll cover:
- The fundamentals of clean architecture in Android
- Managing state with StateFlow in the ViewModel
- Building a reactive UI with Jetpack Compose
- Best practices for error handling and testing in modern Android applications
Understanding Clean Architecture
Clean Architecture is a layered approach to organizing your codebase, focusing on the separation of concerns, testability, and maintainability.
In Android, it typically follows three main layers:
1. Presentation(UI) Layer: Responsible for the UI and user interaction.
2. Domain Layer: Contains business logic and use cases, completely decoupled from any specific Android framework components.
3. Data Layer: Manages data sources, such as local databases or remote APIs.
The image above illustrates the recommended Clean Architecture layers for Android, showing how the Presentation, Domain, and Data layers interact while maintaining separation of concerns. It highlights how dependencies flow inward, ensuring modularity and testability.
Setting Up the Data Layer
The Data Layer handles data retrieval. In this example, we’ll simulate fetching data from an API. It’s a simple repository pattern:
interface ApiService {
suspend fun fetchData(): String
}
interface DataRepository {
suspend fun getData(): Result<String>
}
class DataRepositoryImpl(private val api: ApiService) : DataRepository {
override suspend fun getData(): Result<String> {
return try {
val response = api.fetchData()
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
}
Best Practices:
- Use repository patterns for data management.
- Handle both online and offline data scenarios gracefully.
- Ensure robust error handling in data retrieval.
Defining the Domain Layer
The Domain Layer in Clean Architecture is where the business logic lives. We’ll define a use case to fetch data, which is independent of any data sources (local or remote).
interface FetchDataUseCase {
suspend fun execute(): Result<String>
}
class FetchDataUseCaseImpl(private val repository: DataRepository) : FetchDataUseCase {
override suspend fun execute(): Result<String> {
return repository.getData()
}
}
The FetchDataUseCase depends on a DataRepository interface, which will be implemented in the Data Layer. The Result type helps us gracefully handle success and error cases.
Best Practices:
- Keep business logic separated.
- Use interfaces for better decoupling.
ViewModel with StateFlow
Next, we connect the Domain Layer to the Presentation Layer via a ViewModel. The ViewModel manages the state with StateFlow and exposes it to the UI. The state will be represented using sealed classes to handle loading, success, and error scenarios.
sealed class UiState {
object Loading : UiState()
data class Success(val data: String) : UiState()
data class Error(val message: String) : UiState()
}
class MainViewModel(private val fetchDataUseCase: FetchDataUseCase) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState
init {
fetchData()
}
private fun fetchData() {
viewModelScope.launch {
_uiState.value = UiState.Loading
val result = fetchDataUseCase.execute()
_uiState.value = if (result.isSuccess) {
UiState.Success(result.getOrNull() ?: "")
} else {
UiState.Error(ERROR_FETCHING_DATA)
}
}
}
}
Best Practices:
- Use StateFlow for state management in ViewModels.
- Represent UI states with sealed classes.
- Avoid direct business logic in ViewModels.
Building the UI with Jetpack Compose
We can now create our UI in Jetpack Compose that responds to the state changes from the ViewModel:
@Composable
fun MainScreen(uiState: UiState) {
when (uiState) {
is UiState.Loading -> {
CircularProgressIndicator()
}
is UiState.Success -> {
Text(text = (uiState as UiState.Success).data)
}
is UiState.Error -> {
Text(text = (uiState as UiState.Error).message, color = Color.Red)
}
}
}
- Avoid passing viewModels in composables
Error Handling
Error handling is crucial in modern apps. By using Kotlin’s Result class we ensure that errors are handled gracefully at every step. Whether the issue is a network failure or a backend error, the app can display appropriate feedback to the user without crashing.
Testing the Clean Architecture
Create a test double fake repository
class FakeRepository: DataRepository {
var errorToReturn: Exception? = null
override suspend fun getData(): Result<String> {
return if (errorToReturn != null) {
Result.failure(errorToReturn!!)
} else {
Result.success("Test Data")
}
}
}
Create our ViewModel testing class
@ExperimentalCoroutinesApi
class MainViewModelTest {
private lateinit var viewModel: MainViewModel
private lateinit var fakeRepository: FakeRepository
@BeforeEach
fun setUp() {
val testDispatcher = StandardTestDispatcher()
Dispatchers.setMain(testDispatcher)
fakeRepository = FakeRepository()
val fetchDataUseCase = FetchDataUseCaseImpl(fakeRepository)
viewModel = MainViewModel(fetchDataUseCase)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `fetchData emits Success state`() = runTest {
viewModel.fetchData()
advanceUntilIdle()
assertEquals(UiState.Success("Test Data"), viewModel.uiState.value)
}
@Test
fun `fetchData emits Error state`() = runTest {
fakeRepository.errorToReturn = Exception("Test Error")
viewModel.fetchData()
advanceUntilIdle()
assertEquals(UiState.Error("Test Error"), viewModel.uiState.value)
}
}
Code Explanation
- Use StandardTestDispatcher to replace the main dispatcher which makes the execution of our coroutine predictable.
- runTest will allow us to skip delays.
- advanceUntilIdle() will run the coroutine on the scheduler until there are no more queued tasks, ensuring tests can verify the final state accurately.
Conclusion
By combining Clean Architecture with modern state management practices using StateFlow, we’ve built a scalable, testable, and maintainable Android application.
We’ve shown how to separate concerns between layers, handle UI state reactively, and ensure that errors are handled gracefully across the app.
Following these practices not only leads to a better-organized codebase but also ensures that your app remains resilient, even as complexity grows.
If you enjoyed this post, don’t forget to clap and show your support!
Make sure to follow me for the latest insights on software engineering trends.
For more about my work, including links to my LinkedIn, GitHub, and other projects, feel free to visit my portfolio: https://omardroid.github.io/portfolio/
Let’s connect and explore opportunities in mobile software engineering!