Erfan.
← All posts
·12 min read

Clean Architecture on Android: A Practical Guide

A hands-on guide to structuring Android projects with Clean Architecture — covering layers, dependency rules, testing, and real trade-offs from production apps.

AndroidArchitectureKotlinTutorial

Clean Architecture layers diagramClean 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:

  1. Domain depending on Android — if you see import android.* in a use case, something is wrong.
  2. Fat ViewModels — if your ViewModel has more than one responsibility, extract a use case.
  3. Skipping mappers — reusing Room @Entity classes in the UI causes coupling that's painful to unwind later.
  4. Over-engineering small apps — a to-do app doesn't need three Gradle modules. Know when to simplify.

Further reading


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).