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:
Domain layer (innermost)
Contains business logic, use cases, and domain models. No Android dependencies.
Data layer (middle)
Implements data retrieval and storage. Depends on domain interfaces.
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)
}
Benefits
Example use cases
Single Responsibility Principle
Reusable across different features
Easy to test in isolation
Clear business intent
GetArticlesUseCase: Fetch article list
GetArticleByIdUseCase: Get single article
SaveOrRemoveFavoriteArticleUseCase: Toggle favorite status
IsArticleFavoriteUseCase: Check if article is favorited
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))
}
Why coordinate data sources in repositories?
Repositories handle complex data operations:
Fetch from remote API, cache locally
Implement offline-first strategies
Merge data from multiple sources
Handle data synchronization
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
Framework independence
Business logic in the domain layer can be ported to other platforms (iOS, desktop) without changes.
Testability
Use cases and repositories can be unit tested without Android dependencies. Mock implementations are easy to create.
Flexibility
Swap Retrofit for Ktor, Room for SQLDelight, or Compose for XML views without touching business logic.
Maintainability
Clear boundaries between layers make it easy to locate and modify code. Changes in one layer rarely affect others.
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