Why Clean Architecture Still Matters in 2026
The Domain Layer: Entities and Use Cases
// Domain entity -- pure Kotlin, no framework dependencies
data class Article(
val id: ArticleId,
val title: String,
val content: String,
val author: Author,
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 }
// Repository interface -- defined in domain, implemented in data
interface ArticleRepository {
fun getArticles(query: ArticleQuery): Flow<List<Article>>
suspend fun getArticle(id: ArticleId): Article
suspend fun saveArticle(article: Article): Article
suspend fun deleteArticle(id: ArticleId)
}
data class ArticleQuery(
val status: ArticleStatus? = null,
val tag: Tag? = null,
val authorId: String? = null,
val sortBy: SortField = SortField.PUBLISHED_AT,
val limit: Int = 20
)
enum class SortField { PUBLISHED_AT, TITLE, UPDATED_AT }Use Cases: Single-Responsibility Business Operations
// Use case: publish an article with validation and side effects
class PublishArticleUseCase(
private val articleRepo: ArticleRepository,
private val notificationRepo: NotificationRepository,
private val analyticsTracker: AnalyticsTracker
) {
sealed interface Result {
data class Success(val article: Article) : Result
data class ValidationError(val reasons: List<String>) : Result
data class Failure(val cause: Throwable) : Result
}
suspend operator fun invoke(articleId: ArticleId): Result {
// 1. Fetch and validate
val article = try {
articleRepo.getArticle(articleId)
} catch (e: Exception) {
return Result.Failure(e)
}
val errors = validate(article)
if (errors.isNotEmpty()) return Result.ValidationError(errors)
// 2. Update status
val published = article.copy(
status = ArticleStatus.PUBLISHED,
publishedAt = Clock.System.now()
)
return try {
val saved = articleRepo.saveArticle(published)
// 3. Side effects (non-blocking, failures don't roll back)
coroutineScope {
launch { notificationRepo.notifyFollowers(saved.author.id, saved.id) }
launch { analyticsTracker.trackPublish(saved.id) }
}
Result.Success(saved)
} catch (e: Exception) {
Result.Failure(e)
}
}
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")
}
}
// Use case: get trending articles (combines multiple data sources)
class GetTrendingArticlesUseCase(
private val articleRepo: ArticleRepository,
private val analyticsRepo: AnalyticsRepository
) {
operator fun invoke(limit: Int = 10): Flow<List<Article>> {
return analyticsRepo.getTrendingArticleIds(limit)
.flatMapLatest { ids ->
articleRepo.getArticles(
ArticleQuery(status = ArticleStatus.PUBLISHED)
).map { articles ->
articles.filter { it.id in ids }
.sortedBy { ids.indexOf(it.id) }
}
}
}
}The Data Layer: Repository Implementations
class ArticleRepositoryImpl(
private val api: ArticleApi,
private val dao: ArticleDao,
private val mapper: ArticleMapper
) : ArticleRepository {
override fun getArticles(query: ArticleQuery): Flow<List<Article>> {
// Offline-first: emit from DB, then refresh from API
return dao.observeArticles(
status = query.status?.name,
tag = query.tag?.value,
limit = query.limit
).map { entities ->
entities.map(mapper::toDomain)
}.onStart {
// Background refresh -- doesn't block the flow
try {
val remote = api.getArticles(
status = query.status?.name,
tag = query.tag?.value,
limit = query.limit
)
dao.upsertAll(remote.map(mapper::toEntity))
} catch (_: Exception) {
// Offline: local data still flows
}
}
}
override suspend fun getArticle(id: ArticleId): Article {
// Try local first, fall back to network
val local = dao.getById(id.value)
if (local != null) return mapper.toDomain(local)
val remote = api.getArticle(id.value)
dao.upsert(mapper.toEntity(remote))
return mapper.toDomain(mapper.toEntity(remote))
}
override suspend fun saveArticle(article: Article): Article {
val dto = mapper.toDto(article)
val response = api.updateArticle(article.id.value, dto)
val entity = mapper.toEntity(response)
dao.upsert(entity)
return mapper.toDomain(entity)
}
override suspend fun deleteArticle(id: ArticleId) {
api.deleteArticle(id.value)
dao.deleteById(id.value)
}
}
// Mapper: isolates data layer DTOs from domain entities
class ArticleMapper {
fun toDomain(entity: ArticleEntity): Article = Article(
id = ArticleId(entity.id),
title = entity.title,
content = entity.content,
author = Author(entity.authorId, entity.authorName),
publishedAt = Instant.fromEpochMilliseconds(entity.publishedAtMs),
tags = entity.tags.split(",").map { Tag(it.trim()) },
status = ArticleStatus.valueOf(entity.status)
)
fun toEntity(dto: ArticleDto): ArticleEntity = ArticleEntity(
id = dto.id,
title = dto.title,
content = dto.content,
authorId = dto.author.id,
authorName = dto.author.name,
publishedAtMs = dto.publishedAt.toEpochMilliseconds(),
tags = dto.tags.joinToString(","),
status = dto.status
)
fun toDto(article: Article): ArticleUpdateDto = ArticleUpdateDto(
title = article.title,
content = article.content,
tags = article.tags.map { it.value },
status = article.status.name
)
}The Presentation Layer: ViewModels and UI State
@HiltViewModel
class ArticleListViewModel @Inject constructor(
private val getTrending: GetTrendingArticlesUseCase,
private val publishArticle: PublishArticleUseCase
) : ViewModel() {
sealed interface UiState {
data object Loading : UiState
data class Success(
val articles: List<ArticleUi>,
val isPublishing: Boolean = false
) : UiState
data class Error(val message: String) : UiState
}
data class ArticleUi(
val id: String,
val title: String,
val excerpt: String,
val authorName: String,
val publishedDate: String, // Formatted for display
val tagLabels: List<String>
)
val uiState: StateFlow<UiState> = getTrending()
.map<List<Article>, UiState> { articles ->
UiState.Success(articles.map(::toUi))
}
.catch { emit(UiState.Error(it.message ?: "Unknown error")) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading)
fun onPublish(articleId: String) {
viewModelScope.launch {
// Show publishing state
val current = uiState.value as? UiState.Success ?: return@launch
// Use case handles all business logic
when (val result = publishArticle(ArticleId(articleId))) {
is PublishArticleUseCase.Result.Success -> { /* Flow auto-updates */ }
is PublishArticleUseCase.Result.ValidationError -> {
// Show validation errors to user
}
is PublishArticleUseCase.Result.Failure -> {
// Show error snackbar
}
}
}
}
private fun toUi(article: Article) = ArticleUi(
id = article.id.value,
title = article.title,
excerpt = article.content.take(150) + "...",
authorName = article.author.name,
publishedDate = article.publishedAt.formatRelative(),
tagLabels = article.tags.map { it.value }
)
}Module Structure and Dependency Injection
// settings.gradle.kts
include(":app", ":domain", ":data")
// :domain/build.gradle.kts -- ZERO Android dependencies
plugins {
id("org.jetbrains.kotlin.jvm")
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("app.cash.turbine:turbine:1.2.0")
}
// :data/build.gradle.kts -- depends on :domain
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
}
dependencies {
implementation(project(":domain"))
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("androidx.room:room-ktx:2.7.0")
ksp("androidx.room:room-compiler:2.7.0")
implementation("com.google.dagger:hilt-android:2.52")
ksp("com.google.dagger:hilt-android-compiler:2.52")
}
// :app/build.gradle.kts -- depends on :domain and :data
dependencies {
implementation(project(":domain"))
implementation(project(":data"))
implementation("com.google.dagger:hilt-android:2.52")
ksp("com.google.dagger:hilt-android-compiler:2.52")
}
// Hilt wiring: data module provides implementations
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindArticleRepository(
impl: ArticleRepositoryImpl
): ArticleRepository
}
// Hilt wiring: use cases are constructor-injected automatically
// No @Module needed -- Hilt resolves the constructor parameters
class PublishArticleUseCase @Inject constructor(
private val articleRepo: ArticleRepository,
private val notificationRepo: NotificationRepository,
private val analyticsTracker: AnalyticsTracker
)Key Takeaways
- 1The dependency rule is the foundation: inner layers (domain) never depend on outer layers (data, presentation). Enforce it with Gradle module boundaries.
- 2Use cases encapsulate single business operations and are the natural seam for unit testing business logic without Android framework dependencies.
- 3The domain module has zero Android dependencies -- pure Kotlin classes testable with plain JUnit in milliseconds.
- 4Repository interfaces live in the domain layer; implementations live in the data layer. The domain never knows about Retrofit, Room, or any framework.
- 5Mappers isolate data layer DTOs from domain entities, preventing API changes from rippling through the entire codebase.
- 6Clean Architecture is not all-or-nothing: start with the domain layer for your most complex business logic and expand outward as the codebase grows.
Frequently Asked
What is the dependency rule in Clean Architecture?
Inner layers never depend on outer ones: the domain layer knows nothing about data or presentation. Enforce it with Gradle module boundaries so a violation fails the build rather than relying on review.
Do I have to adopt Clean Architecture all at once?
No. It is not all-or-nothing. Start with a domain layer around your most complex business logic and expand outward as the codebase grows.
Where do repository interfaces belong?
The interface lives in the domain layer and the implementation lives in the data layer. The domain never references Retrofit, Room, or any framework type.
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.