Skip to main content
The Compose Project Template strictly follows Clean Architecture principles as defined by Robert C. Martin (Uncle Bob). This approach creates a system that is testable, maintainable, and independent of frameworks.

Core principles

Clean Architecture is built on fundamental principles that guide the entire project structure:

Independence of frameworks

The architecture doesn’t depend on feature-rich libraries. Frameworks are tools, not constraints.

Testability

Business rules can be tested without the UI, database, or external services.

Independence of UI

The UI can change without changing the rest of the system.

Independence of database

Business rules are not bound to the database implementation.

The dependency rule

The most important rule in Clean Architecture: source code dependencies must point only inward toward higher-level policies.
Nothing in an inner circle can know anything about something in an outer circle. Inner layers define interfaces that outer layers implement.

Layer hierarchy

The project implements three distinct layers:
1

Domain layer (innermost)

Contains business logic, use cases, and domain models. No Android dependencies.
2

Data layer (middle)

Implements data retrieval and storage. Depends on domain interfaces.
3

Presentation layer (outermost)

Handles UI and user interactions. Depends on domain use cases.

Domain layer: The heart of the application

The domain layer is the most stable and contains the business rules. It’s organized into three modules:

domain/model

Defines business objects (Bo suffix) that represent domain entities:
data class ArticleBo(
    val id: Long,
    val title: String,
    val authors: List<AuthorBo>,
    val url: String,
    val imageUrl: String,
    val newsSite: String,
    val summary: String,
    val publishedAt: String,
    val updatedAt: String,
)
Business objects use the Bo suffix to distinguish them from DTOs (data transfer objects) and DBOs (database objects).

domain/gateway

Defines repository interfaces that the data layer implements. This inverts the dependency:
interface ArticleGateway {
    suspend fun getArticles(
        limit: Long,
        offset: Long,
    ): Flow<ArticleResponseBo>

    suspend fun getArticleById(id: Long): Flow<ArticleBo>
    
    suspend fun getFavoriteArticles(): Flow<List<ArticleBo>>
    
    suspend fun saveFavoriteArticle(articleBo: ArticleBo)
    
    suspend fun removeFavoriteArticle(articleBo: ArticleBo)
    
    suspend fun isArticleFavorite(id: Long): Flow<Boolean>
}
Key characteristics:
  • Defined in domain layer
  • Implemented in data layer
  • Uses domain models (Bo), not DTOs or DBOs
  • Framework-agnostic (uses Kotlin Flow, not Android LiveData)

domain/useCase

Encapsulates single business operations. Each use case has one responsibility:
interface GetArticlesUseCase {
    suspend operator fun invoke(
        limit: Long,
        offset: Long
    ): Flow<ArticleResponseBo>
}

class GetArticlesUseCaseImpl(
    private val articleGateway: ArticleGateway,
) : GetArticlesUseCase {
    override suspend fun invoke(
        limit: Long,
        offset: Long
    ): Flow<ArticleResponseBo> = articleGateway.getArticles(limit, offset)
}
  • Single Responsibility Principle
  • Reusable across different features
  • Easy to test in isolation
  • Clear business intent

Domain layer dependencies

The domain layer has zero Android dependencies:
dependencies {
    // Only pure Kotlin and KotlinX libraries
    implementation(projects.domain.model)
    implementation(libs.androidx.core.ktx)  // Only for Flow types
    implementation(libs.androidx.appcompat)
}
Never add Android framework dependencies to the domain layer. This keeps business logic portable and testable.

Data layer: Implementation of abstractions

The data layer implements domain gateway interfaces and provides data from various sources.

data/source

Defines data source interfaces (the contracts):
interface ArticleRemoteDataSource {
    suspend fun getArticles(limit: Long, offset: Long): ArticleResponseBo
    suspend fun getArticleById(id: Long): ArticleBo
}

interface ArticleLocalDataSource {
    suspend fun saveFavoriteArticle(article: ArticleBo)
    suspend fun removeFavoriteArticle(article: ArticleBo)
    suspend fun getFavoriteArticles(): List<ArticleBo>
    suspend fun isArticleFavorite(id: Long): Boolean
}

data/remote

Implements remote data source using Retrofit:
class ArticleRemoteDataSourceImpl(
    val articleWs: ArticleWs
) : ArticleRemoteDataSource {
    override suspend fun getArticles(
        limit: Long,
        offset: Long
    ): ArticleResponseBo = articleWs.getArticles(limit, offset).toBo()
    
    override suspend fun getArticleById(id: Long): ArticleBo = 
        articleWs.getArticleById(id).toBo()
}
The toBo() extension function converts DTOs (from API) to business objects (domain models).

data/local

Implements local data source using Room:
class ArticleLocalDataSourceImpl(
    roomDatabase: AppRoomDatabase,
) : ArticleLocalDataSource {
    private val articleDao = roomDatabase.articleDao()
    
    override suspend fun saveFavoriteArticle(article: ArticleBo) = 
        articleDao.saveFavoriteArticle(article.toDbo())
    
    override suspend fun removeFavoriteArticle(article: ArticleBo) = 
        articleDao.removeFavoriteArticle(article.toDbo())
    
    override suspend fun getFavoriteArticles(): List<ArticleBo> = 
        articleDao.getFavoriteArticles().map { it.toBo() }
    
    override suspend fun isArticleFavorite(id: Long) = 
        articleDao.getFavoriteArticleById(articleId = id) != null
}

data/repository

Coordinates between data sources and implements gateway interfaces:
class ArticleRepository(
    private val remote: ArticleRemoteDataSource,
    private val local: ArticleLocalDataSource,
) : ArticleGateway {
    override suspend fun getArticles(
        limit: Long,
        offset: Long
    ): Flow<ArticleResponseBo> = flowOf(remote.getArticles(limit, offset))
    
    override suspend fun getArticleById(id: Long): Flow<ArticleBo> = 
        flowOf(remote.getArticleById(id))
    
    override suspend fun getFavoriteArticles(): Flow<List<ArticleBo>> = 
        flowOf(local.getFavoriteArticles())
    
    override suspend fun saveFavoriteArticle(articleBo: ArticleBo) = 
        local.saveFavoriteArticle(articleBo)
    
    override suspend fun removeFavoriteArticle(articleBo: ArticleBo) = 
        local.removeFavoriteArticle(articleBo)
    
    override suspend fun isArticleFavorite(id: Long) = 
        flowOf(local.isArticleFavorite(id))
}
Repositories handle complex data operations:
  • Fetch from remote API, cache locally
  • Implement offline-first strategies
  • Merge data from multiple sources
  • Handle data synchronization
API Response (DTO) → toBo() → Business Object (Bo)
Business Object (Bo) → toDbo() → Database Object (DBO)
Database Object (DBO) → toBo() → Business Object (Bo)

Presentation layer: UI and user interaction

The presentation layer contains feature modules that implement UI using Jetpack Compose and MVVM pattern.

Feature module structure

Each feature module follows a consistent structure:
feature/home/
├── component/           # Reusable Composables
│   ├── HomeScreenContent.kt
│   └── HomeScreenTopBar.kt
├── screen/             # Screen-level Composables
│   └── HomeScreen.kt
├── state/              # UI state definitions
│   └── HomeUiState.kt
└── viewmodel/          # ViewModels
    └── HomeViewModel.kt

ViewModel implementation

ViewModels orchestrate use cases and manage UI state:
@HiltViewModel
class HomeViewModel @Inject constructor(
    private val getArticlesUseCase: GetArticlesUseCase,
    private val getFavoriteArticlesUseCase: GetFavoriteArticlesUseCase,
    private val saveOrRemoveFavoriteArticleUseCase: SaveOrRemoveFavoriteArticleUseCase,
    private val saveLastOpenTimeUseCase: SaveLastOpenTimeUseCase,
    private val getLastOpenTimeUseCase: GetLastOpenTimeUseCase,
) : BaseViewModel<HomeUiState>() {
    override val uiState: MutableStateFlow<UiState<HomeUiState>> = 
        MutableStateFlow(value = UiState(data = HomeUiState()))
    
    init {
        viewModelScope.launch(Dispatchers.IO) {
            fetchData()
        }
    }
    
    private suspend fun getArticles() =
        getArticlesUseCase(limit = 5L, offset = 0L).collectLatest { response ->
            uiState.successState { currentUiState ->
                currentUiState.copy(articles = response.results)
            }
        }
}
ViewModels only depend on use case interfaces, not repositories or data sources. This keeps the presentation layer decoupled from data implementation.

UI state management

The project uses a custom BaseViewModel with state helpers:
abstract class BaseViewModel<T> : ViewModel() {
    protected abstract val uiState: MutableStateFlow<UiState<T>>
    
    fun getUiState(): StateFlow<UiState<T>> = uiState.asStateFlow()
    
    // Helper functions for state updates
    fun MutableStateFlow<UiState<T>>.updateState(block: (T) -> T) {
        update { currentUiState ->
            currentUiState.copy(data = block(currentUiState.data))
        }
    }
    
    fun MutableStateFlow<UiState<T>>.loadingState() {
        update { currentUiState ->
            currentUiState.copy(isLoading = true)
        }
    }
    
    fun MutableStateFlow<UiState<T>>.successState(block: (T) -> T) {
        update { currentUiState ->
            currentUiState.copy(data = block(currentUiState.data), isLoading = false)
        }
    }
}

Benefits of clean architecture in this project

1

Framework independence

Business logic in the domain layer can be ported to other platforms (iOS, desktop) without changes.
2

Testability

Use cases and repositories can be unit tested without Android dependencies. Mock implementations are easy to create.
3

Flexibility

Swap Retrofit for Ktor, Room for SQLDelight, or Compose for XML views without touching business logic.
4

Maintainability

Clear boundaries between layers make it easy to locate and modify code. Changes in one layer rarely affect others.
5

Scalability

New features can be added as independent modules. Team members can work in parallel without conflicts.

Common pitfalls to avoid

Don’t pass Android types (Context, Bundle, Intent) to the domain layer. Use domain models and primitives.
Don’t make use cases depend on other use cases. If logic is shared, extract it to a separate use case or domain service.
Don’t put business logic in ViewModels or Repositories. Business logic belongs in use cases.
Don’t use DTOs or DBOs outside their respective modules. Always convert to business objects at module boundaries.

Next steps

Modules

Deep dive into module organization

Dependency injection

Learn about Hilt DI patterns

Build docs developers (and LLMs) love