Friday, 7 November 2025

Clean Architecture in Android — Part 3 || Real-World Implementation with API, Database & Offline-First Design

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