ANDROID-ARCHITECT

AI-powered Android development assistant.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:37:22
UP00:01
MOD · ARTICLE · ARCHITECTURES/N · AX-CLEAN-PUBLISHED
ArchitectureMar 27, 202616 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

Why Clean Architecture Still Matters in 2026

Every Android project reaches a tipping point where adding a feature requires understanding the entire codebase. Activities depend on repositories that depend on Retrofit interfaces that depend on Room DAOs. A change in the API response format ripples through the UI layer. Unit testing requires mocking half the Android framework. Clean Architecture addresses this by enforcing a strict dependency rule: inner layers know nothing about outer layers. Your domain logic doesn't know about Retrofit, Room, or even Android itself. This isn't academic purity -- it's the difference between a codebase that scales to 50 modules and one that collapses under its own weight at 10. The architecture divides your code into three concentric layers: Domain (business rules and entities), Data (repository implementations, data sources), and Presentation (ViewModels, UI). Dependencies point inward, and boundaries are defined by interfaces. Google's Guide to app architecture formalizes this layering for Android.

The Domain Layer: Entities and Use Cases

The domain layer is the heart of Clean Architecture. It contains business entities (pure Kotlin data classes with no framework dependencies), repository interfaces (contracts that the data layer implements), and use cases (single-responsibility classes that orchestrate business operations). Use cases are the most debated aspect of Clean Architecture. Some developers see them as unnecessary wrappers. But use cases shine when business logic spans multiple repositories, requires validation, or needs to be reused across different ViewModels. They are the single source of truth for "how does this business operation work."
kotlin
// 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

Each use case class encapsulates exactly one business operation. The naming convention is a verb phrase: `PublishArticleUseCase`, `GetTrendingArticlesUseCase`, `BookmarkArticleUseCase`. This makes use cases self-documenting and easy to find. A use case depends on repository interfaces (never implementations) and can coordinate multiple repositories. For example, publishing an article might involve saving it, sending a notification, and updating analytics -- all orchestrated in a single use case with proper error handling.
kotlin
// 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

The data layer implements the repository interfaces defined in the domain. This is where Retrofit, Room, DataStore, and other framework-specific code lives. The repository pattern provides a clean abstraction over data sources, and the domain layer never knows whether data comes from a network API, a local database, or an in-memory cache. A well-implemented repository coordinates between remote and local data sources, handling caching, conflict resolution, and offline support. The offline-first pattern is particularly powerful: always read from the local database (fast, works offline), and sync with the API in the background.
kotlin
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

ViewModels depend on use cases, never on repositories directly. This keeps the UI layer thin and focused on mapping domain results to UI state. A single sealed interface represents the complete screen state, making it impossible for the UI to render an inconsistent combination of loading, data, and error states. This layer is the only place that depends on Android framework classes. Use cases and entities remain pure Kotlin, which means your business logic can be tested with plain JUnit -- no Robolectric, no instrumentation, no emulator.
kotlin
@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

Clean Architecture modules follow the dependency rule strictly. The `:domain` module has zero Android dependencies. The `:data` module depends on `:domain` and provides repository implementations. The `:presentation` (or `:app`) module depends on both and wires everything together via Hilt. This module structure enforces architectural boundaries at the build system level, aligning with Google's architecture recommendations. A developer physically cannot import Retrofit in the domain module because the dependency doesn't exist. Compile-time enforcement is stronger than any code review.
kotlin
// 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
)
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1The dependency rule is the foundation: inner layers (domain) never depend on outer layers (data, presentation). Enforce it with Gradle module boundaries.
  2. 2Use cases encapsulate single business operations and are the natural seam for unit testing business logic without Android framework dependencies.
  3. 3The domain module has zero Android dependencies -- pure Kotlin classes testable with plain JUnit in milliseconds.
  4. 4Repository interfaces live in the domain layer; implementations live in the data layer. The domain never knows about Retrofit, Room, or any framework.
  5. 5Mappers isolate data layer DTOs from domain entities, preventing API changes from rippling through the entire codebase.
  6. 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.
MOD · FAQ3 ENTRIESANSWERED

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.

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