A production-level guide to integrating Clean Architecture in a real Android project using Kotlin, Coroutines, Hilt, Room, and Retrofit.
In Part 1, we discussed why Clean Architecture matters.
In Part 2, we built a practical feature using the same principles.
Now, in Part 3, we’ll take it further — implementing a real production-ready feature with API calls, local caching, and offline-first design.
🏗 Project Setup Overview
com.example.fitnessapp ┣ data ┃ ┣ api/ ┃ ┣ db/ ┃ ┣ repository/ ┃ ┗ mapper/ ┣ domain ┃ ┣ model/ ┃ ┣ repository/ ┃ ┗ usecase/ ┣ presentation ┃ ┣ viewmodel/ ┃ ┗ ui/ ┗ di/
The Domain layer defines “what to do,” while the Data layer defines “how to do it.” Remember — dependencies must always flow inward toward the domain.
1️⃣ Step — API Layer (Retrofit + DTOs)
Defines your network structure. Keep DTOs inside the data layer — never expose them to domain or UI.
interface WorkoutApi {
@GET("workouts/{id}")
suspend fun getWorkout(@Path("id") id: Int): WorkoutDto
@GET("workouts")
suspend fun getAllWorkouts(): List<WorkoutDto>
}
data class WorkoutDto(
val id: Int,
val title: String,
val duration: Int,
val calories: Int
)
2️⃣ Step — Local Database (Room)
Room manages persistence and caching for offline-first design.
@Entity(tableName = "workout_table")
data class WorkoutEntity(
@PrimaryKey val id: Int,
val title: String,
val duration: Int,
val calories: Int
)
@Dao
interface WorkoutDao {
@Query("SELECT * FROM workout_table")
suspend fun getAll(): List<WorkoutEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(workouts: List<WorkoutEntity>)
}
3️⃣ Step — Mapper (DTO ↔ Entity ↔ Domain)
Keep mappers small and simple — they isolate API or DB model changes from business logic.
fun WorkoutDto.toEntity() = WorkoutEntity(id, title, duration, calories) fun WorkoutEntity.toDomain() = Workout(id, title, duration, calories)
4️⃣ Step — Repository (Bridge Between Layers)
Domain Layer — Contract
interface WorkoutRepository {
suspend fun getAllWorkouts(forceRefresh: Boolean = false): List<Workout>
}
The domain defines “what” should be done — the data layer decides “how.”
Data Layer — Implementation
class WorkoutRepositoryImpl @Inject constructor(
private val api: WorkoutApi,
private val dao: WorkoutDao
) : WorkoutRepository {
override suspend fun getAllWorkouts(forceRefresh: Boolean): List<Workout> {
return try {
if (forceRefresh) {
val remoteData = api.getAllWorkouts()
dao.insertAll(remoteData.map { it.toEntity() })
}
dao.getAll().map { it.toDomain() }
} catch (e: Exception) {
dao.getAll().map { it.toDomain() } // fallback to cache
}
}
}
Why two repositories?
WorkoutRepository (Domain) defines the interface,
WorkoutRepositoryImpl (Data) implements it — ensuring testability and modular design.
5️⃣ Step — Domain Layer (Business Logic)
data class Workout(val id: Int, val title: String, val duration: Int, val calories: Int)
class GetAllWorkoutsUseCase @Inject constructor(
private val repository: WorkoutRepository
) {
suspend operator fun invoke(forceRefresh: Boolean = false) =
repository.getAllWorkouts(forceRefresh)
}
6️⃣ Step — Presentation Layer (ViewModel + Compose)
@HiltViewModel
class WorkoutViewModel @Inject constructor(
private val getAllWorkoutsUseCase: GetAllWorkoutsUseCase
) : ViewModel() {
private val _state = MutableStateFlow<List<Workout>>(emptyList())
val state: StateFlow<List<Workout>> = _state
fun loadWorkouts(refresh: Boolean = false) {
viewModelScope.launch {
_state.value = getAllWorkoutsUseCase(refresh)
}
}
}
And the UI using Jetpack Compose:
@Composable
fun WorkoutListScreen(viewModel: WorkoutViewModel = hiltViewModel()) {
val workouts by viewModel.state.collectAsState()
LazyColumn {
items(workouts) { workout ->
WorkoutCard(workout)
}
}
}
Keep your UI reactive — let ViewModel manage all logic.
7️⃣ Step — Dependency Injection (Hilt)
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides fun provideApi(): WorkoutApi = Retrofit.Builder()
.baseUrl("https://api.fitness.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(WorkoutApi::class.java)
@Provides fun provideDatabase(app: Application) =
Room.databaseBuilder(app, AppDatabase::class.java, "workout_db").build()
@Provides fun provideDao(db: AppDatabase) = db.workoutDao()
@Provides fun provideRepository(api: WorkoutApi, dao: WorkoutDao): WorkoutRepository =
WorkoutRepositoryImpl(api, dao)
}
8️⃣ Step — Testing & Scalability
- ✅ Replace real repository with fake one in tests.
- ✅ Add new features without breaking existing layers.
- ✅ Keep domain pure — no Android or Retrofit imports.
class FakeWorkoutRepository : WorkoutRepository {
override suspend fun getAllWorkouts(forceRefresh: Boolean) = listOf(
Workout(1, "Morning Run", 30, 200)
)
}
🚫 Common Pitfalls
- ❌ Calling Retrofit directly from ViewModel.
- ❌ Mixing Entity, DTO, and Domain models.
- ❌ Creating one giant global repository for all data.
🔑 Key Takeaways
- Domain defines contracts → Data implements them.
- Dependencies flow inward: UI → Domain → Data.
- Use Mappers to isolate API and DB model changes.
- Hilt + Coroutines + Compose = scalable, testable Android apps.
🚀 Coming Next — Part 4
“Scaling Clean Architecture for Multi-Module Android Projects”
We’ll explore how to split large apps into independent modules, improve build speed, and maintain boundaries as your project grows.
👉 Read now: Clean Architecture in Android — Part 4
✨ Thanks for reading! If this helped you, consider sharing it with fellow developers. Stay tuned for more real-world Android architecture guides. 🚀
No comments:
Post a Comment