Clean Architecture for Android — Without Over-Engineering
A practical guide to Clean Architecture in Android — when to use it, how to structure layers, and how to avoid the over-abstraction trap that kills productivity.
Clean Architecture is the most misunderstood pattern in Android. Teams adopt it, create 47 packages, write interfaces for everything, and end up with more boilerplate than business logic. The architecture is supposed to make code easier to change — but when done wrong, every change requires touching 6 files.
This post covers Clean Architecture as it actually helps: clear layer boundaries, dependency rules, and knowing when to simplify.
The core idea
Clean Architecture has one rule: dependencies point inward. Outer layers know about inner layers. Inner layers know nothing about outer layers.
┌──────────────────────────────────────┐
│ Presentation │ ← Activities, Fragments, ViewModels
│ ┌──────────────────────────────┐ │
│ │ Domain │ │ ← Use Cases, Entities, Repository interfaces
│ │ ┌──────────────────────┐ │ │
│ │ │ Data │ │ │ ← Repository implementations, API, DB
│ │ └──────────────────────┘ │ │
│ └──────────────────────────────┘ │
└──────────────────────────────────────┘
Wait — that looks inverted. In practice, the dependency direction is:
- Presentation depends on Domain
- Data depends on Domain
- Domain depends on nothing
The Domain layer is the center. It has no Android imports, no framework code, no library dependencies. It’s pure Kotlin.
The three layers
Domain layer
Contains:
- Entities — core data classes that represent your business objects
- Repository interfaces — contracts for data access (not implementations)
- Use cases — single-purpose classes that execute business operations
// Entity
data class Article(
val id: String,
val title: String,
val content: String,
val author: String,
val publishedAt: Long,
val bookmarked: Boolean = false
)
// Repository interface
interface ArticleRepository {
suspend fun getArticles(): List<Article>
suspend fun getArticle(id: String): Article
suspend fun bookmarkArticle(id: String)
suspend fun removeBookmark(id: String)
}
// Use case
class GetArticlesUseCase(
private val repository: ArticleRepository
) {
suspend operator fun invoke(): List<Article> {
return repository.getArticles()
.sortedByDescending { it.publishedAt }
}
}
The GetArticlesUseCase doesn’t know where articles come from — API, database, cache. It just calls the repository interface. The sorting logic is business logic that belongs here, not in the ViewModel or repository.
Data layer
Implements the repository interfaces defined in the Domain layer:
class ArticleRepositoryImpl(
private val api: ArticleApi,
private val dao: ArticleDao
) : ArticleRepository {
override suspend fun getArticles(): List<Article> {
return try {
val articles = api.getArticles().map { it.toDomain() }
dao.insertAll(articles.map { it.toEntity() })
articles
} catch (e: IOException) {
dao.getAll().map { it.toDomain() }
}
}
override suspend fun getArticle(id: String): Article {
return api.getArticle(id).toDomain()
}
override suspend fun bookmarkArticle(id: String) {
dao.setBookmarked(id, true)
api.bookmark(id)
}
override suspend fun removeBookmark(id: String) {
dao.setBookmarked(id, false)
api.removeBookmark(id)
}
}
The Data layer contains:
- API service interfaces (Retrofit)
- Room DAOs and entities
- Mapper functions between API/DB models and domain entities
- Repository implementations
Presentation layer
ViewModels, Activities, Fragments, Compose screens:
class ArticlesViewModel(
private val getArticles: GetArticlesUseCase,
private val bookmarkArticle: BookmarkArticleUseCase
) : ViewModel() {
private val _state = MutableStateFlow<ScreenState<List<Article>>>(ScreenState.Loading)
val state: StateFlow<ScreenState<List<Article>>> = _state.asStateFlow()
fun loadArticles() {
viewModelScope.launch {
_state.value = ScreenState.Loading
try {
val articles = getArticles()
_state.value = ScreenState.Success(articles)
} catch (e: Exception) {
_state.value = ScreenState.Error(e.message ?: "Failed to load")
}
}
}
fun onBookmarkClicked(articleId: String) {
viewModelScope.launch {
bookmarkArticle(articleId)
loadArticles()
}
}
}
The ViewModel depends on use cases, not on repositories directly. It doesn’t know about Retrofit or Room.
Package structure
A practical structure that works without being excessive:
com.example.app/
├── data/
│ ├── api/
│ │ ├── ArticleApi.kt
│ │ └── ArticleDto.kt
│ ├── db/
│ │ ├── ArticleDao.kt
│ │ └── ArticleEntity.kt
│ ├── mapper/
│ │ └── ArticleMapper.kt
│ └── repository/
│ └── ArticleRepositoryImpl.kt
├── domain/
│ ├── model/
│ │ └── Article.kt
│ ├── repository/
│ │ └── ArticleRepository.kt
│ └── usecase/
│ ├── GetArticlesUseCase.kt
│ └── BookmarkArticleUseCase.kt
└── presentation/
├── articles/
│ ├── ArticlesViewModel.kt
│ └── ArticlesScreen.kt
└── detail/
├── ArticleDetailViewModel.kt
└── ArticleDetailScreen.kt
Group by feature within the presentation layer. Group by type within data and domain. This keeps related code close without creating a maze.
Mappers between layers
Each layer has its own data classes. You need mappers to convert between them:
// API DTO
data class ArticleDto(
val id: String,
val title: String,
val content: String,
val author_name: String,
val published_at: String
)
// Room Entity
@Entity(tableName = "articles")
data class ArticleEntity(
@PrimaryKey val id: String,
val title: String,
val content: String,
val author: String,
val publishedAt: Long,
val bookmarked: Boolean
)
// Mapper functions
fun ArticleDto.toDomain() = Article(
id = id,
title = title,
content = content,
author = author_name,
publishedAt = parseIsoDate(published_at)
)
fun ArticleEntity.toDomain() = Article(
id = id,
title = title,
content = content,
author = author,
publishedAt = publishedAt,
bookmarked = bookmarked
)
fun Article.toEntity() = ArticleEntity(
id = id,
title = title,
content = content,
author = author,
publishedAt = publishedAt,
bookmarked = bookmarked
)
Why separate models? The API might have author_name (snake_case), Room needs @Entity annotations, and your domain model should be clean of both. When the API changes its field names, only the DTO and mapper change — domain and presentation are untouched.
The use case debate
The most controversial part: are use cases always needed?
When use cases help
class TransferMoneyUseCase(
private val accountRepository: AccountRepository,
private val transactionRepository: TransactionRepository,
private val notificationService: NotificationService
) {
suspend operator fun invoke(from: String, to: String, amount: Double) {
val fromAccount = accountRepository.get(from)
require(fromAccount.balance >= amount) { "Insufficient funds" }
accountRepository.debit(from, amount)
accountRepository.credit(to, amount)
transactionRepository.record(from, to, amount)
notificationService.notifyTransfer(from, to, amount)
}
}
This use case orchestrates multiple repositories. It contains business logic (balance check). It’s worth having.
When use cases are noise
class GetUserByIdUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(id: String): User {
return repository.getUserById(id)
}
}
This use case does nothing. It just forwards to the repository. In this case, have the ViewModel call the repository directly. Add a use case later when actual logic appears.
Rule of thumb: Create a use case when it orchestrates multiple data sources or contains business logic. Skip it when it’s a pass-through.
Dependency injection
Use a DI framework to wire the layers together. With Hilt:
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides
@Singleton
fun provideArticleApi(retrofit: Retrofit): ArticleApi {
return retrofit.create(ArticleApi::class.java)
}
@Provides
@Singleton
fun provideArticleRepository(
api: ArticleApi,
dao: ArticleDao
): ArticleRepository {
return ArticleRepositoryImpl(api, dao)
}
}
@Module
@InstallIn(ViewModelComponent::class)
object DomainModule {
@Provides
fun provideGetArticlesUseCase(
repository: ArticleRepository
): GetArticlesUseCase {
return GetArticlesUseCase(repository)
}
}
The ViewModel receives use cases via constructor injection. It never creates them directly. This makes testing straightforward — inject fakes in tests.
Testing each layer
Domain (use cases)
Pure Kotlin, no Android dependencies. Test with JUnit and fakes:
class GetArticlesUseCaseTest {
@Test
fun `articles are sorted by date descending`() = runTest {
val fakeRepository = FakeArticleRepository(
articles = listOf(
Article(id = "1", title = "Old", publishedAt = 1000L),
Article(id = "2", title = "New", publishedAt = 2000L)
)
)
val useCase = GetArticlesUseCase(fakeRepository)
val result = useCase()
assertEquals("New", result[0].title)
assertEquals("Old", result[1].title)
}
}
Data (repositories)
Test with Testcontainers for database, MockWebServer for API:
class ArticleRepositoryImplTest {
private val mockServer = MockWebServer()
@Test
fun `falls back to cache when API fails`() = runTest {
mockServer.enqueue(MockResponse().setResponseCode(500))
// pre-populate Room with cached data
dao.insertAll(listOf(cachedArticle))
val result = repository.getArticles()
assertEquals(1, result.size)
assertEquals("Cached Article", result[0].title)
}
}
Presentation (ViewModels)
Test with fake use cases:
class ArticlesViewModelTest {
@Test
fun `loading state emitted first`() = runTest {
val viewModel = ArticlesViewModel(
getArticles = FakeGetArticlesUseCase(delay = 100),
bookmarkArticle = FakeBookmarkUseCase()
)
viewModel.loadArticles()
assertEquals(ScreenState.Loading, viewModel.state.value)
}
}
Common over-engineering mistakes
1. Interface for every repository implementation
If you only have one implementation and aren’t writing tests, you don’t need an interface. Add it when you need a second implementation or when you write tests with fakes.
2. Separate modules for each layer
For small-to-medium apps, packages are enough. Gradle modules add build time and complexity. Use modules when your team is large enough that build time matters or you need to enforce visibility boundaries.
3. Mapper classes instead of extension functions
// Over-engineered
class ArticleMapper @Inject constructor() {
fun mapToDomain(dto: ArticleDto): Article { /* ... */ }
fun mapToEntity(article: Article): ArticleEntity { /* ... */ }
}
// Just use extension functions
fun ArticleDto.toDomain(): Article { /* ... */ }
fun Article.toEntity(): ArticleEntity { /* ... */ }
Extension functions are simpler, don’t need DI, and are easier to find.
4. Use cases for every operation
Already covered above. Don’t create pass-through use cases.
When to use Clean Architecture
Good fit:
- Medium-to-large apps with complex business logic
- Apps with multiple data sources (API + cache + local DB)
- Teams where multiple developers work on different features
- Long-lived projects where requirements change often
Overkill:
- Small apps or prototypes
- Apps that are mostly UI with little business logic
- One-person projects where you know the whole codebase
- Apps where the data flow is straightforward (fetch → display)
Start simple, add layers when needed
You don’t have to adopt Clean Architecture from day one. Start with:
- ViewModel + Repository — this covers 80% of apps
- Add domain models when API DTOs diverge from what the UI needs
- Add use cases when business logic appears that doesn’t belong in ViewModel or Repository
- Add separate modules when the team grows and build times suffer
Architecture is a tool. Use the amount you need, not the amount the blog posts tell you to use.