On this page
A common story: a team adopts Clean Architecture after a painful legacy project. Six months later, adding a text field requires touching a DTO, an entity, a domain model, two mappers, a repository interface, its implementation, a use case, a ViewModel and a UI model. Everyone agrees the code is "clean". Nobody agrees it's productive.
This article is for intermediate and senior Android developers who want the benefits of Clean Architecture (testability, replaceable data sources, business logic independent of frameworks) without the ceremony. It aligns the ideas with Google's official architecture guidance, builds one feature end to end, and shares practical lessons about where to draw lines.
The core idea: one rule, not five layers
Strip Clean Architecture down and one rule remains: the dependency rule. Source-code dependencies point inward, toward the business logic. Your business rules don't know about Retrofit, Room, Compose or Android. Frameworks are details that plug in at the edges.
Google's architecture guide expresses a compatible structure in Android terms:
- UI layer: composables or Views plus state holders (ViewModels).
- Domain layer (optional): use cases that encapsulate complex or reused business logic.
- Data layer: repositories that expose data and hide data sources (network, database, files).
Building one feature: "Saved articles"
Requirements: show saved articles (offline-capable), refresh from the server, and let users unsave an article. Saved articles older than 90 days are hidden unless pinned. That last rule is a genuine business rule, and it will justify a use case.
Domain: models and contracts
// Plain Kotlin: no Android, Room or serialization annotations.
data class Article(
val id: String,
val title: String,
val savedAt: java.time.Instant,
val isPinned: Boolean,
)
interface SavedArticlesRepository {
fun observeSaved(): kotlinx.coroutines.flow.Flow<List<Article>>
suspend fun refresh()
suspend fun unsave(id: String)
}import java.time.Clock
import java.time.Duration
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/** Business rule: hide unpinned items saved more than 90 days ago; pinned first, newest first. */
class GetVisibleSavedArticlesUseCase @Inject constructor(
private val repository: SavedArticlesRepository,
private val clock: Clock, // injectable โ deterministic tests
) {
operator fun invoke(): Flow<List<Article>> = repository.observeSaved().map { articles ->
val cutoff = clock.instant().minus(Duration.ofDays(90))
articles
.filter { it.isPinned || it.savedAt.isAfter(cutoff) }
.sortedWith(compareByDescending<Article> { it.isPinned }.thenByDescending { it.savedAt })
}
}Data: repository implementation and mapping
class SavedArticlesRepositoryImpl @Inject constructor(
private val api: SavedArticlesApi, // Retrofit
private val dao: SavedArticleDao, // Room
) : SavedArticlesRepository {
// Room is the single source of truth; the UI always reads from the database.
override fun observeSaved(): Flow<List<Article>> =
dao.observeAll().map { entities -> entities.map { it.toDomain() } }
override suspend fun refresh() {
val remote = api.getSaved() // throws IOException/HttpException
dao.replaceAll(remote.map { it.toEntity() }) // @Transaction in the DAO
}
override suspend fun unsave(id: String) {
dao.delete(id) // optimistic local update
api.unsave(id) // in production: queue with WorkManager on failure
}
}
// Mappers live at the boundary that needs them.
private fun SavedArticleEntity.toDomain() =
Article(id = id, title = title, savedAt = Instant.ofEpochMilli(savedAtMillis), isPinned = pinned)
private fun SavedArticleDto.toEntity() =
SavedArticleEntity(id = id, title = title, savedAtMillis = savedAt, pinned = pinned)UI: ViewModel
data class SavedUiState(
val articles: List<Article> = emptyList(),
val isRefreshing: Boolean = false,
val message: String? = null,
)
@HiltViewModel
class SavedArticlesViewModel @Inject constructor(
getVisibleSaved: GetVisibleSavedArticlesUseCase,
private val repository: SavedArticlesRepository, // simple actions call the repository directly
) : ViewModel() {
private val refreshing = MutableStateFlow(false)
private val message = MutableStateFlow<String?>(null)
val uiState: StateFlow<SavedUiState> =
combine(getVisibleSaved(), refreshing, message) { articles, isRefreshing, msg ->
SavedUiState(articles, isRefreshing, msg)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SavedUiState())
init { refresh() }
fun refresh() = viewModelScope.launch {
refreshing.value = true
try {
repository.refresh()
} catch (e: IOException) {
message.value = "Showing saved copies. Couldn't reach the server."
} finally {
refreshing.value = false
}
}
fun unsave(id: String) = viewModelScope.launch { repository.unsave(id) }
fun messageShown() { message.value = null }
}The 90-day visibility rule lives in a use case because it's real business logic that should be tested and could be reused (for example by a widget). refresh() and unsave() are simple pass-throughs, so the ViewModel calls the repository directly. Google's guidance describes the domain layer as optional for exactly this reason. Wrapper use cases that only forward a call add files, not value.
Wiring with Hilt
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
abstract fun bindSavedArticlesRepository(impl: SavedArticlesRepositoryImpl): SavedArticlesRepository
}
@Module
@InstallIn(SingletonComponent::class)
object TimeModule {
@Provides
fun provideClock(): Clock = Clock.systemUTC()
}Testing pays off here
Because the use case depends only on an interface and a Clock, the business rule is testable with plain JUnit and a fake:
class GetVisibleSavedArticlesUseCaseTest {
private val now = Instant.parse("2026-10-14T10:00:00Z")
private val clock = Clock.fixed(now, ZoneOffset.UTC)
private class FakeRepo(private val items: List<Article>) : SavedArticlesRepository {
override fun observeSaved() = flowOf(items)
override suspend fun refresh() = Unit
override suspend fun unsave(id: String) = Unit
}
@Test
fun `old unpinned articles are hidden, pinned ones stay first`() = runTest {
val old = Article("1", "Old", now.minus(Duration.ofDays(120)), isPinned = false)
val oldPinned = Article("2", "Old pinned", now.minus(Duration.ofDays(200)), isPinned = true)
val recent = Article("3", "Recent", now.minus(Duration.ofDays(2)), isPinned = false)
val result = GetVisibleSavedArticlesUseCase(FakeRepo(listOf(old, oldPinned, recent)), clock)().first()
assertEquals(listOf("2", "3"), result.map { it.id })
}
}Packages first, modules when you need them
Layers are a logical boundary. Gradle modules are a physical one that the compiler enforces. Start with packages per feature and layer. Introduce modules when you have a concrete reason: build times, team ownership, or reuse across apps.
:app โ navigation, DI graph assembly
:feature:saved โ UI + ViewModels for "Saved articles"
:feature:search
:core:domain โ pure Kotlin: models, repository interfaces, use cases
:core:data โ repository implementations
:core:network โ Retrofit setup, DTOs
:core:database โ Room database, DAOs, entities
:core:designsystem โ theme, shared composablesGoogle's modularization guide and the open-source Now in Android sample show this kind of structure in practice.
Practical lessons from real codebases
1. Don't map for the sake of mapping
Separate DTO, entity and domain models when they genuinely differ, for example when the API shape differs from what the app needs or Room needs annotations. If three classes are identical, you've tripled maintenance for no isolation benefit. Introduce mappers when shapes diverge.
2. The repository is the real boundary
Most of the testability and flexibility comes from ViewModels depending on repository interfaces. Get that right before adding anything else.
3. Keep the domain pure Kotlin
No Context, no LiveData, no Room annotations in domain code. A pure-Kotlin :core:domain module enforces this automatically.
4. Name use cases after actions
GetVisibleSavedArticlesUseCase and PlaceOrderUseCase say what they do. ArticleUseCases with 15 methods is a service class with a new name.
5. Decide threading at the edges
Repositories and data sources are main-safe (using withContext internally), so ViewModels and use cases never think about dispatchers.
6. Architecture should match team size
A solo developer with a 6-screen app and a 40-person team with 200 screens need different amounts of structure. Both can be "clean".
When Clean Architecture pays off, and when it doesn't
| Situation | Recommended approach | Why |
|---|---|---|
| Prototype or hackathon | ViewModel + repository, single module | Speed matters; boundaries can come later |
| Small production app (1โ3 devs) | UI + data layers, use cases only for real rules, packages not modules | Testable without ceremony |
| Growing app, several teams | Feature modules + core modules, domain module, strict dependency rules | Ownership, build speed, enforced boundaries |
| Sharing logic with iOS (KMP) | Pure-Kotlin domain and data in shared modules | Clean boundaries make multiplatform sharing much easier |
Common mistakes
- Pass-through use cases everywhere (
GetUserUseCasethat just callsrepository.getUser()). - Leaking framework types inward: Retrofit
Response, Room entities orContextin the domain layer. - Repositories returning UI models such as formatted strings or colours. Formatting belongs in the UI layer.
- One giant "BaseUseCase" hierarchy with generics and callbacks, instead of simple
suspendorFlow-returning functions. - Over-modularising early: 30 modules for 5 screens, with slower syncs and no real benefit.
Frequently asked questions
Is Clean Architecture the same as Google's recommended architecture?
They're compatible but not identical. Google's guide defines UI, optional domain and data layers with unidirectional data flow. Clean Architecture adds the strict dependency rule and terminology from Robert C. Martin. Many Android teams combine the two.
Do I need a domain layer?
Only where there is business logic worth isolating: complex rules, logic reused by several ViewModels, or logic you want to test independently. Simple screens can depend on repositories directly.
MVVM or MVI with Clean Architecture?
Either. Clean Architecture is about the layers below and around the ViewModel. MVVM and MVI describe how the ViewModel and UI communicate. See MVVM vs MVI.
Should repositories return Result types or throw?
Both work. Pick one convention for the codebase. Throwing exceptions from suspend functions and catching them in the ViewModel is simple and idiomatic. Result or sealed types make failures explicit in signatures.
Conclusion
Clean Architecture on Android works best as a few firm rules: dependencies point inward, repositories hide data sources behind interfaces, business logic is plain Kotlin, and frameworks stay at the edges. Add use cases, mappers and modules when they solve a real problem, not because a template says so.
Next step: pick one feature in your app and check two things. Does the ViewModel depend on an interface? Is the business rule testable without Android? Fix those first. Then measure the result with the Android performance optimisation guide, because clean code still has to be fast.
Official references
๐ฑ Apps built by Yunok Tech
Real Android apps on Google Play, free to download.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
Ready to build real Android applications?
Architecture makes the most sense when you apply it to a real project and see why each boundary exists. Learn how maintainable Android applications are designed by building one.
Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.
Explore the Android course and take your next step as an Android developer.