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
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
- The three layers — Presentation (UI + ViewModel), Domain (business logic — use cases + entities), Data (repositories + remote/local data sources). Each layer has a specific responsibility. Dependencies point INWARD only — Presentation depends on Domain, Data depends on Domain, Domain depends on nothing.
- The Dependency Rule — Source code dependencies must point inward toward higher-level policies. The Domain layer must have zero knowledge of Android, Retrofit, Room, or Compose. It is pure Kotlin. This makes it independently testable with plain JUnit — no Robolectric, no instrumented tests required.
- Domain entities vs data models — Entities are the core business objects defined in the Domain layer (data class Order, data class User). They represent business concepts, not database schemas or API shapes. Data models (network DTOs, Room entities) live in the Data layer and are mapped to/from Domain entities. Never leak ORM annotations into Domain.
- Use Cases (Interactors) — A Use Case encapsulates a single business action: class GetActiveOrdersUseCase(private val repo: OrderRepository) { operator fun invoke(): Flow> = repo.getActiveOrders() }. ViewModels call use cases — not repositories directly. Each use case has one job, making it unit-testable in isolation.
- Repository interface in Domain — interface OrderRepository is defined in the Domain layer. It declares what data operations the domain needs, in domain terms (Order entities, not DTOs). The concrete implementation (OrderRepositoryImpl) lives in the Data layer and is never imported by the Domain or Presentation layers.
- Mapper pattern — Mappers convert between layers: OrderDto.toDomain(): Order, OrderEntity.toDomain(): Order, Order.toEntity(): OrderEntity. Each layer converts to its own model at the boundary. This isolates model changes — a backend API field rename only touches OrderDto and its mapper, not the Domain or UI code.
- Package structure by layer — Organize packages by feature + layer: com.app.feature.order.presentation (ViewModel, composables), com.app.feature.order.domain (entities, use cases, repository interface), com.app.feature.order.data (DTOs, Room entities, repo impl, API service). Horizontal slicing by layer is an anti-pattern for large apps — vertical feature slicing is preferred.
- Why ViewModels call Use Cases, not Repositories — ViewModel calling repositories directly couples the Presentation layer to Data concepts (DTOs, Room DAOs). Use cases provide a stable API surface — the ViewModel does not change if the data source changes from REST to GraphQL. Use cases also compose — GetDashboardUseCase can call multiple repository operations.
- Testing at each layer — Domain: pure JUnit unit tests — no mocks for Android (no Android SDK dependency). Use fake repository implementations instead of mocks for use case tests. Data: integration tests with Room in-memory DB and MockWebServer for Retrofit. Presentation: ViewModel unit tests with fake use cases, UI tests with ComposeTestRule.
- The Dependency Inversion Principle (DIP) — High-level modules (Domain) should not depend on low-level modules (Data). Both should depend on abstractions. The OrderRepository interface in Domain is the abstraction — Domain defines it, Data implements it, DI provides the binding. This is implemented via Hilt @Binds in the Data module.
- Common violations in real codebases — Domain entity with @Entity annotation (Room leaked in), ViewModel importing Retrofit Response directly, Use case returning a DTO instead of a domain entity, Repository impl in the same module as Domain entities. These shortcuts accumulate into unmaintainable spaghetti at scale.
- Multi-module support — Clean Architecture maps naturally to Gradle modules: :feature:order:domain, :feature:order:data, :feature:order:presentation. Compile-time enforcement of the dependency rule — :domain module has no Android dependency (apply plugin: 'kotlin'), :presentation cannot import :data directly. Enforced by Gradle, not just convention.
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. 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. AttendanceRepository interface is defined in Domain — it speaks in domain terms (AttendanceRecord, LocalDate) not in data terms (AttendanceEntity, String dates)
- 3. GetClassAttendanceUseCase takes the interface (not the impl) via constructor injection — it cannot see Room or Firebase even if it wanted to
- 4. AttendanceDto in the Data layer mirrors the API response shape with @SerialName annotations — totally separate from the domain entity
- 5. AttendanceEntity in the Data layer has @Entity for Room — this annotation never escapes the Data layer
- 6. toDomain() extension functions are the mappers — they live in the Data layer and convert outward to Domain. Domain never has toDtoOrToEntity functions
- 7. AttendanceRepositoryImpl implements the Domain interface — it decides the offline-first logic (Room as source, remote for sync) entirely within the Data layer
- 8. AttendanceViewModel only imports from the Domain layer — GetClassAttendanceUseCase and MarkAttendanceUseCase. Zero data layer imports
- 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. 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Guide to App Architecture — Android Official (Android Developers)
- Clean Architecture on Android — ProAndroidDev (ProAndroidDev)
- Now in Android — Architecture Case Study (Google GitHub)
- Android Architecture: Clean Architecture Deep Dive (YouTube / PhilippLackner)
- Dependency Injection with Hilt (Android Developers)