Lesson 23 of 83 advanced

Clean Architecture for Android

The architectural foundation that separates every senior Android developer from a junior — layers, dependency rules, and why it matters

Open interactive version (quiz + challenge)

Real-world analogy

Clean Architecture is like a hospital. The doctors (Domain layer) make medical decisions using universal medical knowledge — they don't care if the patient record is on paper, a computer, or a tablet. The nurses (Data layer) handle where records come from and store them. The reception desk (Presentation layer) deals with patients (users) and routes their needs to the right doctor. Crucially, doctors never walk to reception — communication only flows inward.

What is it?

Clean Architecture organizes Android apps into three concentric layers: Presentation (UI/ViewModel), Domain (entities, use cases, repository interfaces — pure Kotlin), and Data (repository implementations, DTOs, Room entities, Retrofit services). The Dependency Rule mandates that all dependencies point inward toward the Domain. Mappers translate between layer-specific models at boundaries. This structure makes the business logic testable without Android, and makes each layer independently changeable.

Real-world relevance

A school management app has a GetStudentAttendanceUseCase in the Domain layer that returns Flow>. The AttendanceRepository interface is defined in Domain. The AttendanceRepositoryImpl in Data decides whether to fetch from Firebase (online) or Room (offline), mapping FirebaseAttendance DTOs and AttendanceEntity Room objects to the domain AttendanceRecord. The AttendanceViewModel calls the use case — it has zero knowledge of Firebase or Room. Swapping Firebase for a REST API touches only the Data layer.

Key points

Code example

// ===== DOMAIN LAYER — pure Kotlin, zero Android imports =====
// com.schoolapp.attendance.domain.model
data class AttendanceRecord(
    val studentId: String,
    val date: LocalDate,
    val status: AttendanceStatus,
    val recordedBy: String
)

enum class AttendanceStatus { PRESENT, ABSENT, LATE }

// com.schoolapp.attendance.domain.repository
interface AttendanceRepository {
    fun getAttendanceForClass(classId: String, date: LocalDate): Flow<List<AttendanceRecord>>
    suspend fun markAttendance(record: AttendanceRecord): Result<Unit>
}

// com.schoolapp.attendance.domain.usecase
class GetClassAttendanceUseCase @Inject constructor(
    private val repository: AttendanceRepository
) {
    operator fun invoke(classId: String, date: LocalDate): Flow<List<AttendanceRecord>> =
        repository.getAttendanceForClass(classId, date)
}

class MarkAttendanceUseCase @Inject constructor(
    private val repository: AttendanceRepository
) {
    suspend operator fun invoke(record: AttendanceRecord): Result<Unit> =
        repository.markAttendance(record)
}

// ===== DATA LAYER — knows about Room, Firebase, Retrofit =====
// com.schoolapp.attendance.data.remote.dto
data class AttendanceDto(
    @SerialName("student_id") val studentId: String,
    @SerialName("date") val date: String,
    @SerialName("status") val status: String,
    @SerialName("recorded_by") val recordedBy: String
)

// com.schoolapp.attendance.data.local.entity
@Entity(tableName = "attendance")
data class AttendanceEntity(
    @PrimaryKey val id: String,
    val studentId: String,
    val date: String,
    val status: String,
    val recordedBy: String
)

// com.schoolapp.attendance.data.mapper
fun AttendanceDto.toDomain(): AttendanceRecord = AttendanceRecord(
    studentId = studentId,
    date = LocalDate.parse(date),
    status = AttendanceStatus.valueOf(status.uppercase()),
    recordedBy = recordedBy
)

fun AttendanceEntity.toDomain(): AttendanceRecord = AttendanceRecord(
    studentId = studentId,
    date = LocalDate.parse(date),
    status = AttendanceStatus.valueOf(status.uppercase()),
    recordedBy = recordedBy
)

fun AttendanceRecord.toEntity(id: String): AttendanceEntity = AttendanceEntity(
    id = id,
    studentId = studentId,
    date = date.toString(),
    status = status.name,
    recordedBy = recordedBy
)

// com.schoolapp.attendance.data.repository
class AttendanceRepositoryImpl @Inject constructor(
    private val remoteDataSource: AttendanceRemoteDataSource,
    private val localDataSource: AttendanceLocalDataSource
) : AttendanceRepository {

    override fun getAttendanceForClass(
        classId: String, date: LocalDate
    ): Flow<List<AttendanceRecord>> =
        localDataSource.observeAttendance(classId, date.toString())
            .map { entities -> entities.map { it.toDomain() } }

    override suspend fun markAttendance(record: AttendanceRecord): Result<Unit> = runCatching {
        val entity = record.toEntity(id = UUID.randomUUID().toString())
        localDataSource.insert(entity)
        remoteDataSource.sync(entity)
    }
}

// ===== PRESENTATION LAYER =====
// com.schoolapp.attendance.presentation
@HiltViewModel
class AttendanceViewModel @Inject constructor(
    private val getClassAttendance: GetClassAttendanceUseCase,   // Domain use cases only
    private val markAttendance: MarkAttendanceUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow(AttendanceUiState())
    val uiState: StateFlow<AttendanceUiState> = _uiState.asStateFlow()

    fun loadAttendance(classId: String, date: LocalDate) {
        viewModelScope.launch {
            getClassAttendance(classId, date)
                .collect { records ->
                    _uiState.update { it.copy(records = records.toImmutableList()) }
                }
        }
    }
}

// ===== DI BINDING — Data module =====
@Module @InstallIn(SingletonComponent::class)
abstract class AttendanceModule {
    @Binds @Singleton
    abstract fun bindAttendanceRepository(
        impl: AttendanceRepositoryImpl
    ): AttendanceRepository  // Domain interface ← Data impl
}

Line-by-line walkthrough

  1. 1. AttendanceRecord is a pure Kotlin data class in the Domain layer — no @Entity, no @SerialName, no Android imports — it represents the business concept of an attendance record
  2. 2. AttendanceRepository interface is defined in Domain — it speaks in domain terms (AttendanceRecord, LocalDate) not in data terms (AttendanceEntity, String dates)
  3. 3. GetClassAttendanceUseCase takes the interface (not the impl) via constructor injection — it cannot see Room or Firebase even if it wanted to
  4. 4. AttendanceDto in the Data layer mirrors the API response shape with @SerialName annotations — totally separate from the domain entity
  5. 5. AttendanceEntity in the Data layer has @Entity for Room — this annotation never escapes the Data layer
  6. 6. toDomain() extension functions are the mappers — they live in the Data layer and convert outward to Domain. Domain never has toDtoOrToEntity functions
  7. 7. AttendanceRepositoryImpl implements the Domain interface — it decides the offline-first logic (Room as source, remote for sync) entirely within the Data layer
  8. 8. AttendanceViewModel only imports from the Domain layer — GetClassAttendanceUseCase and MarkAttendanceUseCase. Zero data layer imports
  9. 9. @Binds in the Hilt module tells Hilt: when Domain asks for AttendanceRepository, inject AttendanceRepositoryImpl — this is the inversion of control that makes the Dependency Rule work at runtime
  10. 10. Multi-module enforcement: :feature:attendance:domain module has no Android SDK plugin — if a developer accidentally imports Room, the Gradle build fails

Spot the bug

// Domain layer — com.app.orders.domain
import androidx.room.Entity  // Bug 1
import retrofit2.Response     // Bug 2

@Entity(tableName = "orders")  // Bug 3
data class Order(
    val id: String,
    val total: Double,
    val status: String
)

class GetOrdersUseCase @Inject constructor(
    private val repository: OrderRepositoryImpl  // Bug 4
) {
    suspend fun execute(): Response<List<Order>> =  // Bug 5
        repository.fetchOrders()
}

// Presentation layer — ViewModel
class OrderViewModel @Inject constructor(
    private val repository: OrderRepositoryImpl  // Bug 6
) : ViewModel() {
    fun load() {
        viewModelScope.launch {
            val result = repository.fetchOrders()  // Bug 7
        }
    }
}
Need a hint?
Count the layer violations, leaked dependencies, and wrong return types.
Show answer
Bug 1 & 2: Domain layer imports androidx.room and retrofit2 — both are framework/data-layer dependencies. Domain must be pure Kotlin with zero Android/framework imports. Bug 3: @Entity on a Domain entity leaks Room into Domain — the Order class is now a Room entity, coupling Domain to the Data layer's persistence technology. Fix: Create a separate OrderEntity in the Data layer with @Entity, and keep Order in Domain clean. Bug 4: GetOrdersUseCase depends on OrderRepositoryImpl (the concrete class) instead of OrderRepository (the interface). This defeats the Dependency Inversion Principle — Domain should only know the interface it defines, never the concrete implementation in the Data layer. Bug 5: Use case returns Response<List<Order>> — Response<T> is a Retrofit type, leaking Data layer concerns into Domain. Use cases should return domain-native types like Result<List<Order>> or Flow<List<Order>>. Bug 6: ViewModel injects OrderRepositoryImpl directly instead of calling use cases. This couples Presentation to Data. Fix: Inject GetOrdersUseCase instead. Bug 7: Calling repository.fetchOrders() directly from ViewModel skips the use case layer entirely — business logic that should be in the use case (validation, transformation, caching policy) is bypassed or duplicated in the ViewModel.

Explain like I'm 5

Think of Clean Architecture like a sandwich shop. The inside of the sandwich (Domain) is the most important part — it's pure food, it doesn't care what kind of wrapper it comes in. The wrapper (Data) knows where the ingredients come from — the fridge (Room), the delivery truck (API). The cashier (Presentation/ViewModel) takes your order and communicates it inward. Crucially, the filling never knows about the cashier or the packaging — it's just food. That's why you can change the wrapper (swap API for GraphQL) without touching the filling.

Fun fact

Clean Architecture was introduced by Robert C. Martin (Uncle Bob) in 2012, but the principles trace back to Ivar Jacobson's Use Case Driven Design from the early 1990s and even earlier to David Parnas' information hiding principle from 1972. The Android community adopted it around 2017-2018 as apps grew complex enough that the God Activity and MVC patterns became unmaintainable. Now, Google's official Architecture Guide directly recommends it.

Hands-on challenge

Design the Clean Architecture structure for a 'Task Management' feature: GetPendingTasksUseCase, CompleteTaskUseCase, SyncTasksUseCase. Define the Task domain entity, TaskRepository interface, TaskDto (API), TaskEntity (Room), and all mappers. Implement TaskRepositoryImpl with offline-first logic (return Room flow, sync with API on background). Write unit tests for CompleteTaskUseCase using a FakeTaskRepository. No Android imports in Domain or tests.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery