Clean Architecture layers diagram
Every Android project starts clean. Then reality hits — a tight deadline, a "quick fix", a feature that doesn't quite fit the structure. Six months later, you have a God Activity and a ViewModel that does network calls directly.
Clean Architecture is the answer most teams reach for. But most guides stop at the diagram. This one won't.
What is Clean Architecture?
Clean Architecture, introduced by Robert C. Martin (Uncle Bob), is a layering strategy built around one rule:
Dependencies must always point inward. Outer layers depend on inner layers — never the reverse.
In Android terms, this means three layers:
| Layer | Responsibility | Depends on | | --- | --- | --- | | Presentation | UI, ViewModels, state | Domain only | | Domain | Business logic, use cases, entities | Nothing | | Data | API, database, repositories | Domain (interfaces) |
The Domain layer is the heart. It knows nothing about Android, Retrofit, or Room. This is what makes it testable in pure JVM without an emulator.
Setting up the modules
For a production app, split layers into Gradle modules:
app/
├── presentation/ # Compose UI + ViewModels
├── domain/ # Use cases + entities + repo interfaces
└── data/ # Retrofit + Room + repo implementations
In your settings.gradle.kts:
include(":app")
include(":presentation")
include(":domain")
include(":data")
And in app/build.gradle.kts, only the :app module wires everything together:
dependencies {
implementation(project(":presentation"))
implementation(project(":data"))
// domain is pulled transitively through presentation
}
Start with packages, not modules. Extract to modules only when compile times or team size justify it. Premature modularization creates friction without benefit.
The Domain layer
This is the only layer with zero Android dependencies. Pure Kotlin.
Entities
Entities are plain data classes. No @Entity Room annotation here — that belongs in the data layer.
data class Photo(
val id: String,
val latitude: Double,
val longitude: Double,
val takenAt: Instant,
val url: String,
)
Repository interfaces
Define what you need, not how it's done:
interface PhotoRepository {
fun getPhotosNearby(lat: Double, lng: Double, radiusKm: Double): Flow<List<Photo>>
suspend fun uploadPhoto(photo: Photo): Result<Photo>
}
Use cases
One class, one job. Use cases orchestrate entities and repositories:
class GetPhotosNearbyUseCase(
private val photoRepository: PhotoRepository,
private val locationRepository: LocationRepository,
) {
operator fun invoke(): Flow<List<Photo>> =
locationRepository
.currentLocation()
.flatMapLatest { location ->
photoRepository.getPhotosNearby(
lat = location.latitude,
lng = location.longitude,
radiusKm = DEFAULT_RADIUS_KM,
)
}
companion object {
private const val DEFAULT_RADIUS_KM = 5.0
}
}
Some teams skip use cases for simple CRUD and call repositories directly from ViewModels. That's fine for simple screens — but introduce use cases as soon as business logic appears.
The Data layer
Here's where Android-specific dependencies live. The data layer implements the domain interfaces.
Repository implementation
class PhotoRepositoryImpl(
private val api: PhotoApi,
private val dao: PhotoDao,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : PhotoRepository {
override fun getPhotosNearby(
lat: Double,
lng: Double,
radiusKm: Double,
): Flow<List<Photo>> = flow {
// Emit cached data first
val cached = dao.getAll().map { it.toDomain() }
emit(cached)
// Then fetch fresh data
val remote = api.getPhotosNearby(lat, lng, radiusKm)
dao.upsertAll(remote.map { it.toEntity() })
emit(remote.map { it.toDomain() })
}.flowOn(dispatcher)
override suspend fun uploadPhoto(photo: Photo): Result<Photo> =
runCatching {
api.uploadPhoto(photo.toRequest()).toDomain()
}
}
Mapping functions
Keep mapping between layers explicit. Never reuse the same data class across layers.
// data/mapper/PhotoMapper.kt
fun PhotoDto.toDomain(): Photo = Photo(
id = id,
latitude = lat,
longitude = lng,
takenAt = Instant.parse(takenAt),
url = url,
)
fun PhotoEntity.toDomain(): Photo = Photo(
id = id,
latitude = latitude,
longitude = longitude,
takenAt = takenAt,
url = url,
)
The Presentation layer
ViewModels consume use cases and expose UI state as a single sealed class or data class:
data class MapUiState(
val photos: List<Photo> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)
@HiltViewModel
class MapViewModel @Inject constructor(
private val getPhotosNearby: GetPhotosNearbyUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow(MapUiState())
val uiState: StateFlow<MapUiState> = _uiState.asStateFlow()
init {
loadPhotos()
}
private fun loadPhotos() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
getPhotosNearby()
.catch { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
.collect { photos ->
_uiState.update { it.copy(photos = photos, isLoading = false) }
}
}
}
}
The Compose screen observes state:
@Composable
fun MapScreen(viewModel: MapViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) {
MapLibreMap(photos = uiState.photos)
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
uiState.error?.let { error ->
ErrorSnackbar(message = error)
}
}
}
Testing each layer
This is where Clean Architecture pays off.
Domain — pure JVM, no mocks needed
class GetPhotosNearbyUseCaseTest {
@Test
fun `emits photos from repository filtered by current location`() = runTest {
val fakeLocationRepo = FakeLocationRepository(
location = Location(lat = 45.0, lng = 7.0)
)
val fakePhotoRepo = FakePhotoRepository(
photos = listOf(
Photo(id = "1", latitude = 45.01, longitude = 7.01, ...)
)
)
val useCase = GetPhotosNearbyUseCase(fakePhotoRepo, fakeLocationRepo)
val result = useCase().first()
assertEquals(1, result.size)
}
}
ViewModel — with kotlinx-coroutines-test
@OptIn(ExperimentalCoroutinesApi::class)
class MapViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
@Test
fun `loading state is false after photos are loaded`() = runTest {
val viewModel = MapViewModel(FakeGetPhotosNearbyUseCase())
viewModel.uiState.test {
val initial = awaitItem()
assertTrue(initial.isLoading)
val loaded = awaitItem()
assertFalse(loaded.isLoading)
assertEquals(3, loaded.photos.size)
}
}
}
Common mistakes
Here's what I've seen go wrong in production codebases:
- Domain depending on Android — if you see
import android.*in a use case, something is wrong. - Fat ViewModels — if your ViewModel has more than one responsibility, extract a use case.
- Skipping mappers — reusing Room
@Entityclasses in the UI causes coupling that's painful to unwind later. - Over-engineering small apps — a to-do app doesn't need three Gradle modules. Know when to simplify.
Further reading
- Clean Architecture — Robert C. Martin
- Android Architecture Guide — developer.android.com
- Now in Android sample app — Google's reference implementation
- Kotlin Coroutines best practices
This architecture is what I use across all my freelance projects, including Fotoshi and Android Solid Services. If you have questions or want a code review, [get in touch](/# contact).