Introduction
Modern Android applications are becoming increasingly complex. Features such as authentication, offline support, payments, notifications, and real-time synchronization require an architecture that can scale as the application grows.
Without proper architecture, Android projects often become difficult to maintain. UI components start containing business logic, network code becomes mixed with database operations, and testing becomes complicated.
Clean Architecture provides a structured approach by separating responsibilities into independent layers. Combined with Kotlin, Jetpack Compose, Coroutines, Flow, and dependency injection, it allows developers to create maintainable and testable Android applications.

What Is Clean Architecture?
Clean Architecture is a software design approach introduced by Robert C. Martin that focuses on separation of concerns and dependency management.
The main idea is that business rules should not depend on frameworks, databases, or external systems.
| Problem | Clean Architecture Solution |
|---|---|
| Business logic inside Activities | Move logic into ViewModels and Use Cases |
| Database dependency everywhere | Use repository abstraction |
| Hard testing | Separate independent layers |
| Changing frameworks | Keep core logic independent |
Clean Architecture Dependency Rule
The most important rule of Clean Architecture is that dependencies should always point toward the domain layer.
The inner layers should not know anything about the outer layers.
- ●
Domain should not depend on Android framework
- ●
Domain should not depend on databases
- ●
Business rules should be independent
- ●
External implementations depend on abstractions
Android Clean Architecture Layers
| Layer | Responsibility |
|---|---|
| Presentation | UI, ViewModel, UI State |
| Domain | Business rules and use cases |
| Data | API, database, repositories |
Presentation Layer
The presentation layer contains everything related to displaying information and handling user interactions.
- ●
Jetpack Compose screens
- ●
Activities and Fragments
- ●
ViewModels
- ●
UI state models
- ●
User events
The presentation layer should not contain business rules. Its responsibility is to display state and send user actions to the ViewModel.
ViewModel Example
class UserViewModel(
private val getUsersUseCase: GetUsersUseCase
) : ViewModel() {
private val _state =
MutableStateFlow(
UserState()
)
val state =
_state.asStateFlow()
fun loadUsers() {
viewModelScope.launch {
val users =
getUsersUseCase()
_state.value =
UserState(
users = users
)
}
}
}
UI State Management
Modern Android applications should represent the complete screen state using a single state object.
data class UserState(
val loading: Boolean = false,
val users: List<User> = emptyList(),
val error: String? = null
)
Domain Layer
The domain layer contains the core business logic of the application. It should be independent from Android frameworks and external technologies.
- ●
Use Cases
- ●
Business rules
- ●
Domain models
- ●
Repository interfaces
Use Cases
Use Cases represent individual business operations. They provide a clean entry point between the ViewModel and business logic.
class GetUsersUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke()
: List<User> {
return repository.getUsers()
}
}
Repository Interfaces
The domain layer defines repository interfaces while the data layer provides their implementations.
interface UserRepository {
suspend fun getUsers()
: List<User>
}
Data Layer
The data layer is responsible for providing application data. It communicates with external sources such as REST APIs, local databases, files, and other data providers.
The data layer implements repository interfaces defined in the domain layer. This keeps the business logic independent from specific technologies.
- ●
Remote data sources
- ●
Local data sources
- ●
Repository implementations
- ●
API models
- ●
Database entities
Data Layer Structure
data/
├── remote/
│ ├── ApiService.kt
│ └── UserDto.kt
├── local/
│ ├── UserDao.kt
│ └── UserEntity.kt
├── repository/
│ └── UserRepositoryImpl.kt
Remote Data Source with Retrofit
Retrofit is commonly used for communicating with backend APIs. The remote data source isolates network operations from the rest of the application.
interface UserApi {
@GET("users")
suspend fun getUsers()
: List<UserDto>
}
DTO Models
Data transfer objects represent data received from external systems. They should not be used directly inside the domain layer.
data class UserDto(
val id: Long,
val name: String
)
Mapping Data Models
Mapping converts external models into domain models. This prevents external changes from affecting business logic.
fun UserDto.toDomain()
: User {
return User(
id = id,
name = name
)
}
Repository Implementation
The repository combines different data sources and provides data according to domain requirements.
class UserRepositoryImpl(
private val api: UserApi
) : UserRepository {
override suspend fun getUsers()
: List<User> {
return api.getUsers()
.map {
it.toDomain()
}
}
}
Room Database Integration
Room provides a local database solution for Android applications. In Clean Architecture, Room belongs inside the data layer.
@Dao
interface UserDao {
@Query(
"SELECT * FROM users"
)
fun observeUsers()
: Flow<List<UserEntity>>
}
Offline First Architecture
Modern Android applications often use an offline-first approach where local storage becomes the primary source of data.
The application reads from the database and synchronizes changes with the server when network connectivity is available.
- ●
Better user experience
- ●
Works without internet
- ●
Faster loading times
- ●
Reduced network usage
Repository with Local and Remote Sources
class UserRepositoryImpl(
private val api: UserApi,
private val dao: UserDao
) : UserRepository {
override fun getUsers()
: Flow<List<User>> {
return dao.observeUsers()
.map {
users ->
users.map {
it.toDomain()
}
}
}
}
Dependency Injection with Hilt
Dependency injection allows classes to receive their dependencies instead of creating them internally. Hilt is the recommended dependency injection framework for Android.
- ●
Improved testability
- ●
Loose coupling
- ●
Cleaner architecture
- ●
Simpler dependency management
Providing Repository Dependencies
@Module
@InstallIn(
SingletonComponent::class
)
object RepositoryModule {
@Provides
fun provideUserRepository(
api: UserApi
): UserRepository {
return UserRepositoryImpl(
api
)
}
}
Complete Android Project Structure
app/
presentation/
├── screens/
├── components/
├── viewmodel/
domain/
├── model/
├── usecase/
├── repository/
data/
├── api/
├── database/
├── repository/
di/
├── NetworkModule.kt
├── DatabaseModule.kt
Multi Module Clean Architecture
Large Android applications often separate code into multiple Gradle modules. This improves build performance, ownership, and scalability.
:app
:core
:core:network
:core:database
:domain
:feature:profile
:feature:settings
:feature:authentication
Benefits of Multi Module Architecture
- ●
Faster builds
- ●
Better code organization
- ●
Independent feature development
- ●
Reusable modules
- ●
Clear ownership boundaries
Testing Clean Architecture
One of the biggest advantages of Clean Architecture is improved testability. Each layer can be tested independently.
| Layer | Testing Approach |
|---|---|
| Domain | Unit tests for use cases |
| Presentation | ViewModel and UI tests |
| Data | Repository and API tests |
Testing a Use Case
@Test
fun shouldReturnUsers() = runTest {
val result =
useCase()
assertEquals(
3,
result.size
)
}
Integrating Clean Architecture with Jetpack Compose
Jetpack Compose works naturally with Clean Architecture because composable functions focus only on displaying state while ViewModels handle business communication.
The UI layer observes state exposed by the ViewModel and sends user actions back without knowing how data is loaded or stored.
@Composable
fun UserScreen(
viewModel: UserViewModel
) {
val state by viewModel.state
.collectAsState()
when {
state.loading -> {
CircularProgressIndicator()
}
else -> {
UserList(
state.users
)
}
}
}
Handling User Events
A common Compose architecture pattern is unidirectional data flow. Events move from the UI to the ViewModel, and state moves from the ViewModel back to the UI.
sealed interface UserEvent {
data object Refresh
: UserEvent
data class Delete(
val id: Long
) : UserEvent
}
fun onEvent(
event: UserEvent
) {
when(event) {
UserEvent.Refresh -> {
loadUsers()
}
}
}
Feature Based Package Structure
For larger applications, organizing code by feature is usually better than organizing only by technical layer.
features/
authentication/
data/
domain/
presentation/
profile/
data/
domain/
presentation/
settings/
data/
domain/
presentation/
Feature-based architecture makes it easier for teams to work independently and prevents unrelated features from becoming tightly coupled.
Error Handling Strategy
Production applications need a consistent approach for handling errors from APIs, databases, and business operations.
sealed class Result<T> {
data class Success<T>(
val data: T
) : Result<T>()
data class Error<T>(
val message: String
) : Result<T>()
}
Network Error Handling
Repositories should convert technical errors into meaningful domain results before passing them to the UI layer.
override suspend fun login()
: Result<User> {
return try {
val user =
api.login()
Result.Success(
user
)
}
catch(e: Exception) {
Result.Error(
"Login failed"
)
}
}
Loading, Success, and Error States
A predictable UI state model makes applications easier to maintain and test.
sealed interface ScreenState {
data object Loading
: ScreenState
data class Success(
val data: User
) : ScreenState
data class Error(
val message: String
) : ScreenState
}
Clean Architecture vs MVVM
MVVM and Clean Architecture are often confused because they solve different problems. MVVM is a presentation pattern, while Clean Architecture defines the overall application structure.
| MVVM | Clean Architecture |
|---|---|
| Presentation pattern | Complete architecture approach |
| Defines ViewModel usage | Defines dependency direction |
| Handles UI organization | Handles business separation |
| Can exist alone | Often uses MVVM inside presentation |
Recommended Modern Android Stack
| Purpose | Technology |
|---|---|
| UI | Jetpack Compose |
| State | StateFlow |
| Async | Kotlin Coroutines |
| Architecture | Clean Architecture |
| Dependency Injection | Hilt |
| Networking | Retrofit |
| Database | Room |
| Testing | JUnit + Compose Testing |
Common Clean Architecture Mistakes
- ●
Creating unnecessary abstractions
- ●
Putting business logic inside ViewModels
- ●
Using database entities everywhere
- ●
Making repositories too large
- ●
Ignoring dependency direction
- ●
Creating too many tiny use cases
- ●
Skipping testing
When Should You Use Clean Architecture?
Clean Architecture is especially useful for medium and large applications where long-term maintainability is important.
- ●
Enterprise applications
- ●
Applications with multiple developers
- ●
Apps requiring extensive testing
- ●
Long-lived products
- ●
Complex business rules
For very small applications, a simpler architecture may be enough. The goal is not adding complexity, but creating boundaries where complexity already exists.
Frequently Asked Questions
Is Clean Architecture required for every Android app?
Does Clean Architecture make applications slower?
Should every repository have a use case?
Can Clean Architecture work with Jetpack Compose?
Is Clean Architecture only for Android?
Conclusion
Clean Architecture provides a strong foundation for building scalable Android applications with Kotlin. By separating presentation, business logic, and data responsibilities, developers can create applications that are easier to test, maintain, and extend. Combined with Jetpack Compose, Coroutines, Flow, and modern dependency injection, Clean Architecture represents the recommended approach for professional Android development.
