SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:19
UP00:01
MOD · ARTICLE · ARCHITECTURES/N · AX-CLEAN-PUBLISHED
All Articles
Architecture · Updated 14 MIN

Clean Architecture on Android: Building a Scalable Domain Layer

Implement Clean Architecture with use cases, repository abstractions, and clear dependency rules that make your Android app testable and framework-independent.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Is Clean Architecture the same as Google's app architecture?

No, and this article used to say it was. The earlier version claimed that Google's Guide to app architecture formalizes this layering for Android. Google's own domain layer page carries a note that says the opposite. It observes that the term is also used in clean architecture "and has a different meaning there", and tells readers not to confuse the Android definition "with other definitions you may have read elsewhere". The two share vocabulary and differ on the point that matters most, the direction of dependencies:
Android's guidanceClean Architecture
Is the domain layer required?No. It "is an optional layer that sits between the UI layer and the data layer"Yes. It is the centre
What does a use case depend on?"use case classes usually depend on repository classes" from the data layerA repository interface the domain itself declares
Where does the repository interface live?With the data layerIn the domain; the data layer implements it
Which way does data depend?Domain depends on dataData depends on domain
The right-hand column is the dependency rule: inner layers know nothing about outer ones. The left-hand column is quoted from Google's page. The right-hand column is our summary of the pattern as Android teams apply it. Both are legitimate. Clean Architecture buys a domain module with no Android or library dependency at the price of more interfaces and mappers. Google's version buys less ceremony at the price of a domain that knows about the data layer. The rest of this article builds the Clean version and says where Google's guidance agrees.

The Domain Layer: Entities and Use Cases

In Clean Architecture the domain module holds three things: entities (plain Kotlin types), repository interfaces (contracts the data layer will implement), and use cases. On use cases the two schools agree, and Google's page is the better style guide. A use case does one thing, and is named for it: "verb in present tense + noun/what (optional) + UseCase". It holds no state: "Because use cases shouldn't contain mutable data, you should create a new instance of a use case class every time you pass it as a dependency." And it is safe to call from anywhere: "Use cases from the domain layer must be main-safe". The sample entities use value classes for identifiers, so that an article id cannot be passed where a tag was expected. The time type is java.time.Instant: the module is a plain JVM module, so the JDK is available and nothing from Android is needed.
kotlin
// Domain entity: plain Kotlin, no framework dependency
data class Article(
    val id: ArticleId,
    val title: String,
    val content: String,
    val authorId: String,
    val publishedAt: Instant?,
    val tags: List<Tag>,
    val status: ArticleStatus
)

@JvmInline value class ArticleId(val value: String)
@JvmInline value class Tag(val value: String)

enum class ArticleStatus { DRAFT, PUBLISHED, ARCHIVED }

// Declared in the domain, implemented in the data layer
interface ArticleRepository {
    fun observeArticles(status: ArticleStatus): Flow<List<Article>>
    suspend fun refreshArticles()
    suspend fun getArticle(id: ArticleId): Article
    suspend fun saveArticle(article: Article): Article
}

interface FollowerNotifier {
    suspend fun notifyFollowers(authorId: String, articleId: ArticleId)
}

Use Cases: Single-Responsibility Business Operations

A use case earns its place when there is a rule to enforce or more than one collaborator to coordinate. Publishing an article is both: validate, save, then tell the followers. The earlier version of this sample had two bugs worth showing, because both are common. It claimed the side effects could not fail the publish, and they could. The notification was launched inside coroutineScope, with a comment saying failures would not roll back. coroutineScope waits for its children and fails when one of them fails, so a notification error surfaced as a failed publish for an article that had in fact been saved. The fix is to decide what a notification failure means. Here it is logged and the publish stands, so the call sits in its own try. It caught Exception. In a suspend function that also catches cancellation. The rule, with its source, is in the coroutines deep dive: catch the failures you can handle and let CancellationException through. The clock is a constructor parameter. A use case that calls the system clock directly cannot be tested for its published time without waiting for time to pass.
kotlin
class PublishArticleUseCase(
    private val articles: ArticleRepository,
    private val notifier: FollowerNotifier,
    private val now: () -> Instant
) {
    sealed interface Result {
        data class Success(val article: Article) : Result
        data class Invalid(val reasons: List<String>) : Result
        data class Failed(val cause: IOException) : Result
    }

    suspend operator fun invoke(id: ArticleId): Result {
        val article = try {
            articles.getArticle(id)
        } catch (e: IOException) {
            return Result.Failed(e)
        }

        val reasons = validate(article)
        if (reasons.isNotEmpty()) return Result.Invalid(reasons)

        val saved = try {
            articles.saveArticle(
                article.copy(
                    status = ArticleStatus.PUBLISHED,
                    publishedAt = now()
                )
            )
        } catch (e: IOException) {
            return Result.Failed(e)
        }

        // The article is published. A notification failure
        // is reported, and does not undo that.
        try {
            notifier.notifyFollowers(saved.authorId, saved.id)
        } catch (e: IOException) {
            // log and continue
        }

        return Result.Success(saved)
    }

    private fun validate(article: Article): List<String> = buildList {
        if (article.title.isBlank()) add("Title is required")
        if (article.content.length < 100) {
            add("Content must be at least 100 characters")
        }
        if (article.tags.isEmpty()) add("At least one tag is required")
    }
}

The Data Layer: Repository Implementations

The data module implements the domain's interfaces, and it is the only place that knows about Retrofit and Room. Google's description of a repository's job applies unchanged: "Exposing data to the rest of the app", "Centralizing changes to the data", "Resolving conflicts between multiple data sources" and "Abstracting sources of data from the rest of the app" (Android). The same page gives the rule the implementation follows: "It's important that each repository defines a single source of truth", and for offline support "a local data source" such as a database "is the recommended source of truth". So reads come from the database and the network only ever writes into it. The earlier sample tried to do both in one flow, with the network call inside onStart and a comment saying it did not block. It did: onStart runs to completion before the flow emits anything, so the screen waited for the network before showing cached rows, which defeats the purpose. Reading and refreshing are now two functions. The caller observes immediately and triggers a refresh separately. The fuller version of this pattern, with a sync queue, is in offline-first architecture. Mappers keep three shapes apart: the API's DTO, the database entity and the domain type. When the API renames a field, one mapper changes.
kotlin
class ArticleRepositoryImpl(
    private val api: ArticleApi,
    private val dao: ArticleDao,
    private val mapper: ArticleMapper
) : ArticleRepository {

    // Read: the database is the single source of truth
    override fun observeArticles(
        status: ArticleStatus
    ): Flow<List<Article>> =
        dao.observeByStatus(status.name)
            .map { rows -> rows.map(mapper::toDomain) }

    // Refresh: the network only writes into the database
    override suspend fun refreshArticles() {
        val remote = api.getArticles()
        dao.upsertAll(remote.map(mapper::toEntity))
    }

    override suspend fun getArticle(id: ArticleId): Article {
        dao.getById(id.value)?.let { return mapper.toDomain(it) }

        val entity = mapper.toEntity(api.getArticle(id.value))
        dao.upsert(entity)
        return mapper.toDomain(entity)
    }

    override suspend fun saveArticle(article: Article): Article {
        val response = api.updateArticle(
            article.id.value, mapper.toDto(article)
        )
        val entity = mapper.toEntity(response)
        dao.upsert(entity)
        return mapper.toDomain(entity)
    }
}

The Presentation Layer: ViewModels and UI State

The ViewModel turns domain results into something a screen can draw. It observes the repository's flow, triggers the refresh, and maps a use case result to a message. Should a ViewModel be allowed to call a repository directly? The earlier version of this article said never. Google's page treats it as a trade-off and names the cost of the strict rule: "it forces you to add use cases even when they are just simple function calls to the data layer, which can add complexity for little benefit. A good approach is to add use cases only when required." The sample follows that: publishing has rules, so it has a use case; observing a list does not, so the ViewModel reads the repository interface. The screen state is a sealed interface, so loading, content and error cannot be combined into a state the screen was never designed for. That choice is discussed in MVVM vs MVI.
kotlin
@HiltViewModel
class ArticleListViewModel @Inject constructor(
    private val articles: ArticleRepository,
    private val publishArticle: PublishArticleUseCase
) : ViewModel() {

    sealed interface UiState {
        data object Loading : UiState
        data class Content(val articles: List<ArticleUi>) : UiState
    }

    data class ArticleUi(val id: String, val title: String)

    private val _messages = MutableSharedFlow<String>()
    val messages: SharedFlow<String> = _messages.asSharedFlow()

    val uiState: StateFlow<UiState> =
        articles.observeArticles(ArticleStatus.DRAFT)
            .map<List<Article>, UiState> { list ->
                UiState.Content(
                    list.map { ArticleUi(it.id.value, it.title) }
                )
            }
            .stateIn(
                viewModelScope,
                SharingStarted.WhileSubscribed(5000),
                UiState.Loading
            )

    init {
        viewModelScope.launch {
            try {
                articles.refreshArticles()
            } catch (e: IOException) {
                _messages.emit("Showing saved articles. Refresh failed.")
            }
        }
    }

    fun onPublish(id: String) {
        viewModelScope.launch {
            when (val result = publishArticle(ArticleId(id))) {
                is PublishArticleUseCase.Result.Success -> Unit
                is PublishArticleUseCase.Result.Invalid ->
                    _messages.emit(result.reasons.joinToString())
                is PublishArticleUseCase.Result.Failed ->
                    _messages.emit("Could not publish. Try again.")
            }
        }
    }
}

Module Structure and Dependency Injection

The dependency rule is only real if the build enforces it. Put the domain in its own Gradle module with no Android plugin. A developer cannot import Retrofit there, because the dependency does not exist. That is a stronger guarantee than a code review. - :domain is a Kotlin JVM module. It depends on coroutines and nothing else. - :data is an Android library. It depends on :domain and holds Retrofit, Room and the mappers. - :app depends on both and does the wiring with Hilt. Two details changed since this article was first written. Under Android Gradle plugin 9 the :data module no longer applies the kotlin-android plugin, and the dependency versions belong in a catalog, not in each build file. Both are explained in version catalogs and convention plugins. Use cases have no Hilt annotation in the sample, because the domain module has no Hilt dependency. The app module provides them with a @Provides function. Adding javax.inject to the domain and annotating the constructors is the common shortcut; it trades a little purity for less wiring. Either way, do not scope them. Google's guidance is that use cases "don't have their own lifecycle. Instead, they're scoped to the class that uses them."
kotlin
// settings.gradle.kts
include(":app", ":domain", ":data")

// :domain/build.gradle.kts : no Android plugin at all
plugins {
    id("org.jetbrains.kotlin.jvm")
}
dependencies {
    implementation(libs.coroutines.core)
    testImplementation(libs.coroutines.test)
}

// :data/build.gradle.kts : depends on :domain
plugins {
    id("com.android.library")   // AGP 9 compiles Kotlin itself
    id("com.google.devtools.ksp")
    id("com.google.dagger.hilt.android")
}
dependencies {
    implementation(project(":domain"))
    implementation(libs.retrofit)
    implementation(libs.room.runtime)
    ksp(libs.room.compiler)
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
}

// :data : bind the domain's interface to the implementation
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    abstract fun bindArticleRepository(
        impl: ArticleRepositoryImpl
    ): ArticleRepository
}

// :app : provide the use case, unscoped
@Module
@InstallIn(ViewModelComponent::class)
object UseCaseModule {
    @Provides
    fun providePublishArticle(
        articles: ArticleRepository,
        notifier: FollowerNotifier
    ) = PublishArticleUseCase(articles, notifier, Instant::now)
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Clean Architecture and Google's app architecture are different things. Google's own page says its domain layer has a different meaning.
  2. 2The dependency rule: the domain declares repository interfaces and the data layer implements them. Enforce it with a Gradle module that has no Android plugin.
  3. 3Use cases do one thing, hold no state and are main-safe. Add them where there is a rule to enforce, not for every read.
  4. 4coroutineScope fails when a child fails. Decide explicitly whether a side effect is allowed to fail the operation.
  5. 5The database is the single source of truth. Observe it, and refresh from the network as a separate call.
  6. 6Mappers keep the API shape, the database shape and the domain type apart, so an API change touches one file.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

What is the dependency rule in Clean Architecture?

Inner layers never depend on outer ones. The domain declares the repository interfaces it needs, and the data layer implements them, so the domain knows nothing about Retrofit, Room or Android. Put the domain in a Gradle module with no Android plugin and a violation becomes a compile error, not a review comment.

Is Clean Architecture what Google's architecture guide recommends?

No. Google's guide has an optional domain layer whose use cases depend on repository classes in the data layer, and it warns readers not to confuse its domain layer with the one in clean architecture. Clean Architecture inverts that dependency: the data layer depends on the domain. Choose deliberately; both work.

Do I need a use case for every operation?

No. Google's domain layer page says forcing every call through a use case adds complexity for little benefit when the use case is a simple call to the data layer, and suggests adding use cases only when required. Write one where there is a business rule or several collaborators to coordinate.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA