Clean Architecture is the most discussed and most misunderstood pattern in Android development. Some teams over-engineer it with dozens of empty interfaces and pass-through classes. Others dismiss it entirely and put everything in the ViewModel. The truth is in the middle: Clean Architecture gives you a clear structure with enforced dependency rules that make your app testable, maintainable, and scalable — but only if you apply it pragmatically. This guide shows you how it actually works in Android, when to use it, and when it’s overkill.


The Three Layers

// Clean Architecture divides your app into THREE layers:
//
// ┌─────────────────────────────────────────────────────────────────┐
// │                                                                 │
// │  PRESENTATION LAYER                                             │
// │  UI + ViewModel                                                 │
// │  Activity, Fragment, Composable, ViewModel                      │
// │  Depends on: Domain                                             │
// │                                                                 │
// ├─────────────────────────────────────────────────────────────────┤
// │                                                                 │
// │  DOMAIN LAYER (innermost — pure Kotlin, no Android)             │
// │  Use Cases + Domain Models + Repository Interfaces              │
// │  Business logic that's independent of any framework             │
// │  Depends on: NOTHING (no Android, no Retrofit, no Room)         │
// │                                                                 │
// ├─────────────────────────────────────────────────────────────────┤
// │                                                                 │
// │  DATA LAYER                                                     │
// │  Repository Implementations + Data Sources + DTOs + Entities    │
// │  Retrofit, Room, DataStore, API calls, database queries         │
// │  Depends on: Domain (implements Domain's interfaces)            │
// │                                                                 │
// └─────────────────────────────────────────────────────────────────┘
//
// THE DEPENDENCY RULE:
// Dependencies point INWARD — toward the Domain layer
// Outer layers know about inner layers, NOT the other way around
//
// Presentation → Domain ✅
// Data → Domain ✅
// Domain → Presentation ❌ (domain doesn't know about UI)
// Domain → Data ❌ (domain doesn't know about Retrofit/Room)
//
// Domain is the CENTER — it has ZERO dependencies on anything external

The dependency rule visualised

// ═══ DEPENDENCY DIRECTION ════════════════════════════════════════════
//
//  ┌─────────────────────────────────────────────┐
//  │           PRESENTATION LAYER                 │
//  │     (ViewModel, UI, Activity, Compose)       │
//  │               │                              │
//  │               │ depends on                   │
//  │               ↓                              │
//  │   ┌───────────────────────────┐              │
//  │   │       DOMAIN LAYER        │              │
//  │   │  (Use Cases, Models,      │              │
//  │   │   Repository Interfaces)  │              │
//  │   │           ↑               │              │
//  │   └───────────│───────────────┘              │
//  │               │ implements                   │
//  │               │                              │
//  │   ┌───────────────────────────┐              │
//  │   │        DATA LAYER         │              │
//  │   │  (Repository Impl,       │              │
//  │   │   API, Database, DTO)    │              │
//  │   └───────────────────────────┘              │
//  └─────────────────────────────────────────────┘
//
//  Domain defines an INTERFACE: ArticleRepository
//  Data provides the IMPLEMENTATION: ArticleRepositoryImpl
//  Presentation uses the INTERFACE (doesn't know about the implementation)
//  Hilt wires interface → implementation at compile time

The Domain Layer — Pure Business Logic

The domain layer is the heart of your app. It contains business logic, domain models, and repository interfaces — with zero dependencies on Android, Retrofit, Room, or any framework.

Domain Models

// Domain models are pure Kotlin data classes
// NO framework annotations — no @Entity, no @SerializedName, no @Composable
// They represent your app's BUSINESS concepts

data class Article(
    val id: String,
    val title: String,
    val content: String,
    val author: Author,
    val publishedAt: Long,
    val category: Category,
    val tags: List<String>,
    val isBookmarked: Boolean,
    val viewCount: Int
) {
    val isRecent: Boolean
        // Computed PROPERTY — business logic lives on the model
        get() = System.currentTimeMillis() - publishedAt < 7 * 24 * 60 * 60 * 1000L

    val readTimeMinutes: Int
        get() = (content.split(" ").size / 200).coerceAtLeast(1)
}

data class Author(
    val id: String,
    val name: String,
    val avatarUrl: String?
)

enum class Category {
    // enum class — fixed set of categories
    TECHNOLOGY, SCIENCE, BUSINESS, SPORTS, ENTERTAINMENT
}

// Domain models:
// ✅ Pure Kotlin — no Android imports
// ✅ Business logic as computed properties or extension functions
// ✅ Can be used in ANY Kotlin module (backend, KMP, tests)
// ❌ No Room annotations, no Gson annotations, no Compose annotations

Repository Interface (defined in Domain)

// The Repository INTERFACE lives in the Domain layer
// The IMPLEMENTATION lives in the Data layer
// This is DEPENDENCY INVERSION — Domain defines what it needs,
// Data provides how to do it

interface ArticleRepository {
    // interface — defines the contract without implementation
    // Domain layer knows WHAT data operations exist
    // but NOT HOW they're implemented (Room? Retrofit? Firebase?)

    fun getArticlesFlow(): Flow<List<Article>>
    // Flow is from kotlinx.coroutines — pure Kotlin, not Android-specific

    fun getArticleFlow(id: String): Flow<Article?>

    fun searchArticlesFlow(query: String): Flow<List<Article>>

    suspend fun refreshArticles()
    // suspend KEYWORD — callable from coroutines

    suspend fun toggleBookmark(articleId: String)

    suspend fun deleteArticle(articleId: String)
}

// This interface has:
// ✅ No Retrofit types (no Response<T>, no @GET)
// ✅ No Room types (no Entity, no Dao)
// ✅ No Android types (no Context, no SharedPreferences)
// ✅ Only Kotlin/coroutines types (Flow, suspend, List, String)

Use Cases — Single Business Operations

// A Use Case (also called Interactor) is a CLASS that encapsulates
// ONE specific business operation

// Use Cases contain business logic that:
// - Combines data from multiple repositories
// - Applies business rules
// - Transforms data for a specific use case

class GetArticlesUseCase @Inject constructor(
    // @Inject is an ANNOTATION from javax.inject — Hilt provides dependencies
    private val articleRepository: ArticleRepository,
    // Uses the INTERFACE — doesn't know about the implementation
    private val userRepository: UserRepository
) {
    // operator fun invoke() makes the class callable like a function
    // invoke is an OPERATOR FUNCTION — enables useCase() syntax
    operator fun invoke(): Flow<List<Article>> {
        return combine(
            // combine is a TOP-LEVEL FUNCTION from kotlinx.coroutines.flow
            articleRepository.getArticlesFlow(),
            userRepository.getBookmarkedIdsFlow()
        ) { articles, bookmarkedIds ->
            articles.map { article ->
                article.copy(isBookmarked = article.id in bookmarkedIds)
            }
        }
    }
}

class SearchArticlesUseCase @Inject constructor(
    private val articleRepository: ArticleRepository
) {
    operator fun invoke(query: String): Flow<List<Article>> {
        // Business rule: minimum 2 characters to search
        if (query.length < 2) return flowOf(emptyList())
        // flowOf() is a TOP-LEVEL FUNCTION — creates a Flow that emits one value

        return articleRepository.searchArticlesFlow(query)
    }
}

class ToggleBookmarkUseCase @Inject constructor(
    private val articleRepository: ArticleRepository,
    private val analytics: AnalyticsRepository
) {
    suspend operator fun invoke(articleId: String) {
        // Business logic: toggle bookmark AND log analytics
        articleRepository.toggleBookmark(articleId)
        analytics.logEvent("bookmark_toggled", mapOf("article_id" to articleId))
    }
}

class RefreshArticlesUseCase @Inject constructor(
    private val articleRepository: ArticleRepository
) {
    suspend operator fun invoke() {
        articleRepository.refreshArticles()
    }
}

// Usage in ViewModel — clean and expressive:
val articles = getArticlesUseCase()   // looks like a function call
//            ↑ actually calls operator fun invoke()

viewModelScope.launch {
    toggleBookmarkUseCase(articleId)   // also invoke()
}

When Use Cases are worth it vs overkill

// ✅ USE a Use Case when:
// - Business logic combines MULTIPLE repositories
//   GetArticlesUseCase combines articles + bookmarks
// - Business rules need to be enforced
//   SearchArticlesUseCase enforces minimum query length
// - The same logic is used in MULTIPLE ViewModels
//   ToggleBookmarkUseCase used in ArticleListVM, ArticleDetailVM, BookmarksVM
// - You want to test business logic independently

// ❌ SKIP a Use Case when:
// - It just passes through to a single Repository method
//   class GetArticlesUseCase(private val repo: ArticleRepository) {
//       operator fun invoke() = repo.getArticlesFlow()   // just a proxy!
//   }
//   → ViewModel can call repo.getArticlesFlow() directly — no need for wrapper
//
// - It has no business logic — just data fetching
// - Only ONE ViewModel uses it (no reusability benefit)

// Pragmatic approach:
// Start WITHOUT Use Cases — ViewModel calls Repository directly
// Extract a Use Case WHEN you need to share logic or the ViewModel gets complex
// Don't create Use Cases "just in case" — YAGNI (You Ain't Gonna Need It)

The Data Layer — Implementation Details

// Data layer IMPLEMENTS the interfaces defined in Domain
// It depends ON Domain — Domain does NOT depend on Data

// Repository Implementation
class ArticleRepositoryImpl @Inject constructor(
    private val remoteDataSource: ArticleRemoteDataSource,
    private val localDataSource: ArticleLocalDataSource
) : ArticleRepository {
    // IMPLEMENTS the Domain's ArticleRepository interface

    override fun getArticlesFlow(): Flow<List<Article>> {
        return localDataSource.getArticlesFlow()
            .map { entities -> entities.map { it.toDomain() } }
    }

    override suspend fun refreshArticles() {
        val remote = remoteDataSource.getArticles()
        localDataSource.insertArticles(remote.map { it.toEntity() })
    }

    override suspend fun toggleBookmark(articleId: String) {
        localDataSource.toggleBookmark(articleId)
    }

    override fun getArticleFlow(id: String): Flow<Article?> {
        return localDataSource.getArticleFlow(id)
            .map { it?.toDomain() }
    }

    override fun searchArticlesFlow(query: String): Flow<List<Article>> {
        return localDataSource.searchFlow(query)
            .map { entities -> entities.map { it.toDomain() } }
    }

    override suspend fun deleteArticle(articleId: String) {
        localDataSource.deleteArticle(articleId)
    }
}

// Hilt binds Interface → Implementation
@Module
// @Module is an ANNOTATION from Dagger — provides binding instructions
@InstallIn(SingletonComponent::class)
// @InstallIn is an ANNOTATION — scope to application lifetime
// SingletonComponent is a CLASS — Hilt's app-level component
abstract class RepositoryModule {

    @Binds
    // @Binds is an ANNOTATION from Dagger — binds interface to implementation
    // More efficient than @Provides for interface binding (no instance creation)
    @Singleton
    abstract fun bindArticleRepository(
        impl: ArticleRepositoryImpl
    ): ArticleRepository
    // When someone @Injects ArticleRepository, Hilt provides ArticleRepositoryImpl
}

The Presentation Layer — ViewModel + UI

@HiltViewModel
class ArticleViewModel @Inject constructor(
    private val getArticlesUseCase: GetArticlesUseCase,
    private val refreshArticlesUseCase: RefreshArticlesUseCase,
    private val toggleBookmarkUseCase: ToggleBookmarkUseCase
    // Depends on USE CASES (Domain layer), not Repository directly
    // In simpler apps, you CAN inject Repository directly — that's fine
) : ViewModel() {

    val uiState: StateFlow<UiState> = getArticlesUseCase()
        // operator fun invoke() returns Flow<List<Article>>
        .map<List<Article>, UiState> { articles ->
            UiState.Success(articles)
        }
        .onStart { emit(UiState.Loading) }
        .catch { e -> emit(UiState.Error(e.message ?: "Error")) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading)

    private val _events = MutableSharedFlow<UiEvent>()
    val events: SharedFlow<UiEvent> = _events.asSharedFlow()

    fun refresh() {
        viewModelScope.launch {
            try {
                refreshArticlesUseCase()
                // invoke() — refresh from network
            } catch (e: CancellationException) { throw e }
            catch (e: Exception) {
                _events.emit(UiEvent.ShowSnackbar("Refresh failed"))
            }
        }
    }

    fun onBookmarkToggled(articleId: String) {
        viewModelScope.launch {
            toggleBookmarkUseCase(articleId)
            // invoke() — toggle bookmark + log analytics (all in Use Case)
        }
    }

    sealed interface UiState {
        data object Loading : UiState
        data class Success(val articles: List<Article>) : UiState
        data class Error(val message: String) : UiState
    }

    sealed interface UiEvent {
        data class ShowSnackbar(val message: String) : UiEvent
    }
}

Package / Module Structure

// Option 1: PACKAGES within a single module (simpler apps)
//
// com.example.myapp/
// ├── domain/
// │   ├── model/
// │   │   ├── Article.kt
// │   │   ├── Author.kt
// │   │   └── Category.kt
// │   ├── repository/
// │   │   ├── ArticleRepository.kt      (interface)
// │   │   └── UserRepository.kt         (interface)
// │   └── usecase/
// │       ├── GetArticlesUseCase.kt
// │       ├── SearchArticlesUseCase.kt
// │       └── ToggleBookmarkUseCase.kt
// ├── data/
// │   ├── remote/
// │   │   ├── api/
// │   │   │   └── ArticleApi.kt         (Retrofit interface)
// │   │   ├── dto/
// │   │   │   └── ArticleDto.kt
// │   │   └── ArticleRemoteDataSource.kt
// │   ├── local/
// │   │   ├── dao/
// │   │   │   └── ArticleDao.kt         (Room DAO)
// │   │   ├── entity/
// │   │   │   └── ArticleEntity.kt
// │   │   └── ArticleLocalDataSource.kt
// │   ├── repository/
// │   │   └── ArticleRepositoryImpl.kt  (implements Domain interface)
// │   └── mapper/
// │       └── ArticleMapper.kt          (DTO ↔ Entity ↔ Domain)
// └── presentation/
//     ├── articles/
//     │   ├── ArticleViewModel.kt
//     │   └── ArticleScreen.kt          (Compose)
//     └── detail/
//         ├── DetailViewModel.kt
//         └── DetailScreen.kt


// Option 2: SEPARATE GRADLE MODULES (larger apps)
//
// :domain          — pure Kotlin module (no Android)
//   plugins: kotlin("jvm")
//   dependencies: kotlinx-coroutines-core ONLY
//
// :data            — Android library module
//   plugins: android.library
//   dependencies: :domain, Retrofit, Room, Hilt
//
// :presentation    — Android library or app module
//   plugins: android.application (or android.library for features)
//   dependencies: :domain, Compose, Hilt, Navigation
//
// The Gradle module structure ENFORCES the dependency rule:
// :domain has NO dependency on :data or :presentation
// :data depends on :domain (implements interfaces)
// :presentation depends on :domain (uses Use Cases and models)
// If someone tries to import Retrofit in :domain → compile error!

Complete Data Flow — Visual

// ═══ CLEAN ARCHITECTURE DATA FLOW ════════════════════════════════════
//
//  PRESENTATION                    DOMAIN                     DATA
//  (ViewModel + UI)                (Use Cases + Interfaces)   (Implementations)
//
//  ArticleViewModel                GetArticlesUseCase         ArticleRepositoryImpl
//  ┌──────────────┐               ┌────────────────┐         ┌──────────────────┐
//  │              │── invoke() ──→│                │         │                  │
//  │ uiState =    │               │ combine(       │         │                  │
//  │  getArticles │               │   articleRepo  │──Flow──→│ localDataSource  │
//  │  UseCase()   │               │     .getFlow(),│         │   .getFlow()     │
//  │  .stateIn()  │               │   userRepo     │         │                  │
//  │              │               │     .getIds()) │         │ remoteDataSource │
//  │              │               │                │         │   .getArticles() │
//  └──────────────┘               └────────────────┘         └──────────────────┘
//        │                              │                           │
//        │                              │                           │
//   UI observes                    Pure Kotlin                 Android/3rd party
//   StateFlow                      No Android                  Retrofit, Room
//   (Compose/Fragment)             No framework                DataStore
//
//  Dependencies: Presentation → Domain ← Data
//  Domain is the CENTER — depends on NOTHING

When Clean Architecture is Overkill

// Clean Architecture adds STRUCTURE at the cost of MORE FILES and INDIRECTION
// Not every app needs all three layers with full separation

// ❌ OVERKILL for:
// - Small apps (under 10 screens)
// - Hackathons / prototypes / MVPs
// - Apps with simple CRUD and no complex business logic
// - Solo developer projects with no plans to scale

// For simple apps, MVVM is enough:
// ViewModel → Repository → DataSources
// No Use Cases, no Domain interfaces
// Repository is a concrete class, not an interface

// ✅ WORTH IT for:
// - Large apps (20+ screens) with multiple developers
// - Apps with complex business logic (e-commerce, banking, social)
// - Apps that need to share logic across platforms (KMP)
// - Apps with extensive testing requirements
// - Apps that will be maintained for years

// PRAGMATIC approach:
// 1. Start with MVVM (ViewModel → Repository)
// 2. Add Domain models when DTOs/Entities leak into ViewModel
// 3. Add Use Cases when ViewModel gets complex or logic is shared
// 4. Add Repository interfaces when you need to swap implementations (testing)
// 5. Separate into Gradle modules when the codebase grows

// Don't start with full Clean Architecture on day one
// Let the architecture GROW with the complexity of the app

Common Mistakes to Avoid

Mistake 1: Pass-through Use Cases with no logic

// ❌ Use Case that just delegates — adds a file with zero value
class GetArticlesUseCase @Inject constructor(
    private val repository: ArticleRepository
) {
    operator fun invoke(): Flow<List<Article>> = repository.getArticlesFlow()
    // No business logic — just proxying!
}

// ✅ Either add actual logic or skip the Use Case
// Skip: ViewModel calls repository.getArticlesFlow() directly
// Keep: if it combines repositories, enforces rules, or is shared across ViewModels

Mistake 2: Domain layer depending on Android

// ❌ Android import in Domain — breaks the dependency rule
package com.example.domain.usecase

import android.content.Context   // ❌ Android import in Domain!

class SaveFileUseCase(private val context: Context) { ... }

// ✅ Domain should be pure Kotlin
// If you need file operations, define an interface in Domain:
interface FileStorage {
    suspend fun saveFile(name: String, content: ByteArray)
}
// Implement it in the Data layer with Android's Context

Mistake 3: Over-engineering with too many interfaces

// ❌ Interface for everything — even things that will never be swapped
interface ArticleRemoteDataSource { ... }
interface ArticleLocalDataSource { ... }
interface ArticleMapper { ... }
interface ArticleValidator { ... }
// 10 interfaces, 10 implementations, 10 Hilt modules — for one feature!

// ✅ Interfaces only where they provide VALUE
// Repository interface → YES (swappable for tests, dependency inversion)
// Data Source interface → MAYBE (useful for fakes in testing)
// Mapper interface → NO (pure functions, test directly)
// Validator interface → NO (pure functions, test directly)

// Rule: add an interface when you need to SWAP the implementation
// (testing with fakes, multiple implementations, dependency inversion)

Mistake 4: Domain models with framework annotations

// ❌ Domain model with Room and Gson annotations
@Entity(tableName = "articles")
data class Article(
    @PrimaryKey val id: String,
    @SerializedName("title") val title: String
)
// Domain now depends on Room AND Gson — defeats the purpose!

// ✅ Separate models per layer
// Domain:  data class Article(val id: String, val title: String)
// Entity:  @Entity data class ArticleEntity(@PrimaryKey val id: String, ...)
// DTO:     data class ArticleDto(@SerializedName("title") val title: String, ...)
// Map between them with extension functions

Mistake 5: ViewModel depending on Data layer directly

// ❌ ViewModel imports Retrofit/Room types — coupled to data layer
@HiltViewModel
class ArticleViewModel @Inject constructor(
    private val api: ArticleApi,          // ❌ Retrofit in ViewModel
    private val dao: ArticleDao           // ❌ Room in ViewModel
) : ViewModel()

// ✅ ViewModel depends only on Domain (Repository interface or Use Cases)
@HiltViewModel
class ArticleViewModel @Inject constructor(
    private val getArticlesUseCase: GetArticlesUseCase   // ✅ Domain only
) : ViewModel()

Summary

  • Clean Architecture separates your app into three layers: Presentation (UI + ViewModel), Domain (business logic), Data (implementation details)
  • The Dependency Rule: dependencies point inward — Presentation → Domain ← Data — Domain depends on nothing
  • Domain Layer contains: domain models (pure Kotlin data classes), repository interfaces, and use cases — zero Android/framework dependencies
  • Use Cases (classes with operator fun invoke()) encapsulate single business operations — combine repositories, enforce rules, enable reuse
  • Data Layer implements domain interfaces: ArticleRepositoryImpl implements ArticleRepository interface
  • @Binds (annotation from Dagger) binds interface to implementation in Hilt modules — more efficient than @Provides
  • Use separate models per layer: DTO (network), Entity (database), Domain (business) with mapper extension functions
  • Enforce the dependency rule with Gradle modules: :domain (pure Kotlin), :data (Android), :presentation (Android)
  • Don’t over-engineer: skip pass-through Use Cases, skip unnecessary interfaces, start with MVVM and evolve
  • Pragmatic approach: start with MVVM → add domain models → add Use Cases when needed → separate modules when codebase grows
  • Clean Architecture is worth it for large, complex, long-lived apps with multiple developers and extensive testing
  • Clean Architecture is overkill for small apps, prototypes, and solo projects

Clean Architecture isn’t about following rules dogmatically — it’s about keeping your business logic independent from frameworks so it’s testable, reusable, and maintainable. The Domain layer is the core: pure Kotlin, no dependencies, no frameworks. Everything else is a detail that can change without affecting your business logic. Apply it pragmatically — let the architecture grow with the complexity of your app, not ahead of it.

Happy coding!