Real-World Implementation
In Part 1, we explored the fundamentals of Clean Architecture — how separation of concerns and modular design make your Android projects scalable and testable.
Now, let’s move from theory to practice by implementing a Workout Tracker feature using Clean Architecture principles. We’ll set up the full structure — Domain, Data, and Presentation layers — and use Hilt for dependency injection.
1️⃣ Project Structure
A clean and modular architecture keeps responsibilities isolated. Below is our structure for the Workout Tracker module:
com.example.workouttracker/
│
├── data/
│ ├── repository/
│ ├── source/
│ │ ├── local/
│ │ └── remote/
│ ├── model/
│ └── di/
│
├── domain/
│ ├── model/
│ ├── repository/
│ └── usecase/
│
└── presentation/
├── viewmodel/
├── ui/
└── state/
This structure ensures each layer can evolve independently — the UI can change without touching domain logic, and APIs can be replaced without affecting the ViewModel.
2️⃣ Domain Layer – Use Case & Models
The Domain Layer holds your core business logic. For our feature, let’s define a use case that fetches daily workout sessions.
data class WorkoutSession(
val id: Int,
val name: String,
val duration: Int, // in minutes
val caloriesBurned: Int
)
class GetDailyWorkoutUseCase(
private val repository: WorkoutRepository
) {
operator fun invoke(date: String) = repository.getWorkoutsByDate(date)
}
✅ Pure Kotlin logic
✅ No Android dependencies
✅ Easy to unit test
3️⃣ Data Layer – Repository & Data Sources
The Data Layer merges API and local data, applies caching, and wraps responses in a Result class for consistent handling.
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Throwable) : Result<Nothing>()
object Loading : Result<Nothing>()
}
class WorkoutRepositoryImpl @Inject constructor(
private val api: WorkoutApi,
private val dao: WorkoutDao
) : WorkoutRepository {
override fun getWorkoutsByDate(date: String): Flow<Result<List<WorkoutSession>>> = flow {
emit(Result.Loading)
try {
val remoteData = api.fetchWorkouts(date)
dao.saveAll(remoteData)
emit(Result.Success(remoteData))
} catch (e: Exception) {
val local = dao.getAllByDate(date)
if (local.isNotEmpty()) emit(Result.Success(local))
else emit(Result.Error(e))
}
}
}
Now, network failures are gracefully handled — if remote fails, local cache takes over.
4️⃣ Presentation Layer – ViewModel & UI State
The ViewModel consumes the use case and exposes a reactive state to the UI using StateFlow.
sealed class WorkoutUiState {
object Loading : WorkoutUiState()
data class Success(val data: List<WorkoutSession>) : WorkoutUiState()
data class Error(val message: String) : WorkoutUiState()
}
@HiltViewModel
class WorkoutViewModel @Inject constructor(
private val getDailyWorkoutUseCase: GetDailyWorkoutUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<WorkoutUiState>(WorkoutUiState.Loading)
val uiState: StateFlow<WorkoutUiState> = _uiState
fun loadWorkouts(date: String) {
viewModelScope.launch {
getDailyWorkoutUseCase(date).collect { result ->
when (result) {
is Result.Success -> _uiState.value = WorkoutUiState.Success(result.data)
is Result.Error -> _uiState.value = WorkoutUiState.Error(result.exception.message ?: "Error")
is Result.Loading -> _uiState.value = WorkoutUiState.Loading
}
}
}
}
}
This keeps the ViewModel clean — it doesn’t care where data comes from, only how to represent it.
5️⃣ Dependency Injection with Hilt
Dependency Injection simplifies testing and decouples layers. Here’s how we wire dependencies for our repository and use case.
@Module
@InstallIn(SingletonComponent::class)
object WorkoutModule {
@Provides
fun provideWorkoutRepository(
api: WorkoutApi,
dao: WorkoutDao
): WorkoutRepository = WorkoutRepositoryImpl(api, dao)
@Provides
fun provideGetDailyWorkoutUseCase(
repository: WorkoutRepository
) = GetDailyWorkoutUseCase(repository)
}
Using Hilt, dependencies are auto-injected — keeping code modular and test-ready.
6️⃣ Developer Notes (From Real Experience)
- 💡 Keep ViewModel thin — delegate logic to UseCases.
- 🧱 Wrap API and DB calls in a Result or Resource class.
- ⚙️ Prefer StateFlow over LiveData for reactive state.
- 📦 Keep the Domain layer pure Kotlin — no Android imports.
- 🧩 Make every feature module self-contained and testable.
📘 What’s Next
Next, in Part 3, we’ll cover testing and dependency validation — writing real tests for UseCases, Repositories, and ViewModels using MockK and Coroutines Test.
👉 Part 3: Click Here
Clean Architecture transforms how you build — from code chaos to clarity. Keep iterating, keep learning, and your architecture will reward you with years of maintainable growth. 💪
No comments:
Post a Comment