Skip to content
All posts
March 26, 20264 min read

Clean Architecture in Android: Why It Matters and How to Apply It

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.

AndroidArchitectureKotlin
Share:

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.


The Core Idea

Clean Architecture separates code into layers with a strict dependency rule: outer layers depend on inner layers, never the reverse.

code
┌─────────────────────────────────┐
│  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:

  • Business rules can be tested without a database or network
  • You can swap Room for a different database without touching ViewModel code
  • The UI can be replaced (Compose replacing XML) without touching business logic

The Domain Layer: The Heart of Your App

The Domain layer contains:

Domain models — plain Kotlin classes with no Android or framework dependencies:

kotlin
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:

kotlin
// 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:

kotlin
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: Implementation Details

The Data layer implements the Domain's interfaces:

kotlin
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:

kotlin
// 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
)

The Presentation Layer: UI and ViewModel

ViewModels depend on use cases, not repositories directly:

kotlin
@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.


When Is Clean Architecture Overkill?

For a small app with 3-4 screens and no complex business logic, full Clean Architecture adds boilerplate for no gain:

  • A todo app with local-only storage doesn't need a separate Domain layer
  • Single-screen utility apps don't need use cases

Use judgment. The structure pays off when:

  • The business logic is complex and needs testing
  • Multiple data sources (local + remote)
  • The app will grow and be maintained for years

For simple apps, MVVM + Repository without use cases is often sufficient.


Takeaways

  • Domain layer contains business rules and repository interfaces — pure Kotlin, no Android dependencies
  • Data layer implements repository interfaces — knows about Room, Retrofit, mapping between layers
  • Presentation layer depends on use cases, not repositories directly
  • The dependency rule: outer layers depend on inner layers, Domain depends on nothing
  • Clean Architecture pays off for complex apps; for simple apps, simplified MVVM is sufficient
Share:
S

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.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

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.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this