๐Ÿ›๏ธ Android Architecture and Clean Code

Clean Architecture in Android: Practical Lessons

Clean Architecture is often taught as five layers and twenty files per feature. In real Android apps, the value comes from a few rules applied with judgement. Here are the rules, a complete feature built with them, and the lessons about when they help and when they hurt.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 5 min read ๐ŸŽฏ Advanced
On this page
  1. The core idea: one rule, not five layers
  2. Building one feature: "Saved articles"
  3. Testing pays off here
  4. Packages first, modules when you need them
  5. Practical lessons from real codebases
  6. When Clean Architecture pays off, and when it doesn't
  7. Common mistakes
  8. Frequently asked questions
  9. Conclusion

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).
Android app layers and the dependency rule The UI layer (Compose, ViewModel) depends on the optional domain layer (use cases), which depends on repository interfaces. The data layer implements those interfaces using Retrofit and Room. Arrows show dependencies pointing inward toward the domain; data flows outward to the UI. UI layer Compose screen ViewModel Domain (optional) Use cases Models + repositoryinterfaces Data layer Repository impl Retrofit Room depends on โ†’โ† implements
The data layer depends on the domain's interfaces, not the other way round. Swapping Retrofit for Ktor, or adding a cache, changes nothing above it.

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

domain/Article.ktKotlin
// 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)
}
domain/GetVisibleSavedArticlesUseCase.ktKotlin
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

data/SavedArticlesRepositoryImpl.ktKotlin
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

ui/SavedArticlesViewModel.ktKotlin
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 }
}
Lesson: use cases are optional, per operation

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

di/DataModule.ktKotlin
@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:

GetVisibleSavedArticlesUseCaseTest.ktKotlin
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.

A pragmatic modular structure (when the app grows)Text
: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 composables

Google'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

SituationRecommended approachWhy
Prototype or hackathonViewModel + repository, single moduleSpeed matters; boundaries can come later
Small production app (1โ€“3 devs)UI + data layers, use cases only for real rules, packages not modulesTestable without ceremony
Growing app, several teamsFeature modules + core modules, domain module, strict dependency rulesOwnership, build speed, enforced boundaries
Sharing logic with iOS (KMP)Pure-Kotlin domain and data in shared modulesClean boundaries make multiplatform sharing much easier

Common mistakes

  • Pass-through use cases everywhere (GetUserUseCase that just calls repository.getUser()).
  • Leaking framework types inward: Retrofit Response, Room entities or Context in 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 suspend or Flow-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.

See all our apps โ†’

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.

Share: LinkedIn X / Twitter WhatsApp