MyFamilyTracker
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Clean Architecture is widely referenced and frequently misunderstood. Here's what it actually means for an Android app, what each layer is responsible for, and the concrete patterns that make it worth the structure.
On this page
Clean Architecture appears in every Android job description and is debated in every code review. Most implementations either over-engineer it (endless mapping code for every entity) or under-engineer it (call the database directly from the ViewModel and call it "clean").
Here's what it actually means in practice.
Clean Architecture separates code into layers with a strict dependency rule: outer layers depend on inner layers, never the reverse.
┌─────────────────────────────────┐
│ Presentation (UI, ViewModel) │ ← Depends on Domain
├─────────────────────────────────┤
│ Domain (Use Cases, Models) │ ← Depends on nothing
├─────────────────────────────────┤
│ Data (Repository, DB, API) │ ← Depends on Domain
└─────────────────────────────────┘The Domain layer is the center. It contains your business rules. It doesn't know whether you're using Room or SQLite, Retrofit or Ktor, Compose or XML.
This matters because:
The Domain layer contains:
Domain models — plain Kotlin classes with no Android or framework dependencies:
data class Task(
val id: String,
val title: String,
val completed: Boolean,
val dueDate: LocalDate?,
val priority: Priority
) {
// Business rules live here
val isOverdue: Boolean get() = dueDate?.let { it < LocalDate.now() } ?: false
fun complete(): Task = copy(completed = true)
}Repository interfaces — defined in Domain, implemented in Data:
// Domain defines the contract
interface TaskRepository {
suspend fun getTasks(): List<Task>
suspend fun getTask(id: String): Task?
suspend fun createTask(task: Task)
suspend fun updateTask(task: Task)
suspend fun deleteTask(id: String)
fun observeTasks(): Flow<List<Task>>
}Use cases — single-responsibility operations encapsulating business logic:
class CompleteTaskUseCase(private val repository: TaskRepository) {
suspend operator fun invoke(taskId: String): Result<Task> {
val task = repository.getTask(taskId)
?: return Result.failure(TaskNotFoundException(taskId))
val completedTask = task.complete()
repository.updateTask(completedTask)
return Result.success(completedTask)
}
}The Domain layer has zero Android imports. It's pure Kotlin. It can run on any JVM.
The Data layer implements the Domain's interfaces:
class TaskRepositoryImpl(
private val dao: TaskDao,
private val apiService: TaskApiService,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : TaskRepository {
override suspend fun getTasks(): List<Task> = withContext(ioDispatcher) {
dao.getAllTasks().map { it.toDomain() }
}
override suspend fun createTask(task: Task) = withContext(ioDispatcher) {
dao.insert(task.toEntity())
// Also sync to API if online
try {
apiService.createTask(task.toDto())
} catch (e: Exception) {
// Queue for later sync
pendingSyncDao.enqueue(SyncOperation.Create(task))
}
}
override fun observeTasks(): Flow<List<Task>> =
dao.observeAllTasks().map { entities -> entities.map { it.toDomain() } }
}Data mappers convert between layers:
// Room entity → Domain model
fun TaskEntity.toDomain() = Task(
id = id,
title = title,
completed = completed,
dueDate = dueDate?.let { LocalDate.parse(it) },
priority = Priority.valueOf(priority)
)
// Domain model → Room entity
fun Task.toEntity() = TaskEntity(
id = id,
title = title,
completed = completed,
dueDate = dueDate?.toString(),
priority = priority.name
)ViewModels depend on use cases, not repositories directly:
@HiltViewModel
class TaskViewModel @Inject constructor(
private val getTasksUseCase: GetTasksUseCase,
private val completeTaskUseCase: CompleteTaskUseCase,
private val deleteTaskUseCase: DeleteTaskUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<TaskUiState>(TaskUiState.Loading)
val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()
init {
loadTasks()
}
private fun loadTasks() {
viewModelScope.launch {
getTasksUseCase()
.onStart { _uiState.value = TaskUiState.Loading }
.catch { e -> _uiState.value = TaskUiState.Error(e.message ?: "Unknown error") }
.collect { tasks -> _uiState.value = TaskUiState.Success(tasks) }
}
}
fun completeTask(taskId: String) {
viewModelScope.launch {
completeTaskUseCase(taskId)
.onFailure { e -> /* show error snackbar */ }
}
}
}The ViewModel only knows about use cases and UI state. It doesn't know about Room, Retrofit, or any data implementation detail.
For a small app with 3-4 screens and no complex business logic, full Clean Architecture adds boilerplate for no gain:
Use judgment. The structure pays off when:
For simple apps, MVVM + Repository without use cases is often sufficient.
Sudarshan Chaudhari
AI Systems Builder / Product Engineer
Bangkok, Thailand
Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.
Related Posts
Related Apps
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Building something? Available for Android dev and QA consulting.
Work with meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read