Is Clean Architecture the same as Google's app architecture?
| Android's guidance | Clean 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 layer | A repository interface the domain itself declares |
| Where does the repository interface live? | With the data layer | In the domain; the data layer implements it |
| Which way does data depend? | Domain depends on data | Data depends on domain |
The Domain Layer: Entities and Use Cases
java.time.Instant: the module is a plain JVM module, so the JDK is available and nothing from Android is needed.// 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
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.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
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.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
@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
: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."// 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)
}Key Takeaways
- 1Clean Architecture and Google's app architecture are different things. Google's own page says its domain layer has a different meaning.
- 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.
- 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.
- 4coroutineScope fails when a child fails. Decide explicitly whether a side effect is allowed to fail the operation.
- 5The database is the single source of truth. Observe it, and refresh from the network as a separate call.
- 6Mappers keep the API shape, the database shape and the domain type apart, so an API change touches one file.
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.
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.