Setting up Clean Architecture for Android Applications
An Android project without clear architecture looks predictable: an Activity with 800 lines, Retrofit interface called directly from onClick, Room DAO returning LiveData<List<User>> straight into Fragment. It works until the first requirement: "add caching", "write tests", "extract a shared module for wear OS". Then it becomes clear that everything is tightly coupled.
Clean Architecture solves this through dependency inversion: inner layers don't know about outer ones. Retrofit and Room can be replaced without changing business logic.
Three Layers and Their Boundaries
Domain — the core. Pure Kotlin without Android imports. Here are Entity models, Repository interfaces, UseCase classes. This module compiles to a JVM library and tests without an emulator.
Data — repository implementations. Retrofit DTO, Room Entity, DTO → Domain mapping. UserRepositoryImpl implements UserRepository from Domain and knows about both data sources:
class UserRepositoryImpl @Inject constructor(
private val api: UserApi,
private val dao: UserDao,
private val mapper: UserMapper
) : UserRepository {
override fun getUser(id: String): Flow<User> = flow {
dao.getUser(id)?.let { emit(mapper.fromEntity(it)) }
try {
val remote = api.getUser(id)
dao.upsert(mapper.toEntity(remote))
emit(mapper.fromDto(remote))
} catch (e: HttpException) {
if (dao.getUser(id) == null) throw e
}
}
}
Strategy: emit cache first, refresh from server in parallel. If network fails but cache exists — user sees no error.
Presentation — ViewModel, UI (Compose or XML). Depends only on Domain: calls UseCase, gets Flow, transforms into UI state. Doesn't know where data comes from — Room or Retrofit.
UseCase: When Needed, When Not
UseCase is justified when:
- Orchestrates multiple repositories
- Contains non-trivial business rules
- Reused in multiple ViewModels
GetUserUseCase that only does return userRepository.getUser(id) is an extra layer. If ViewModel works with one repository without logic — inject the repository directly.
class GetUserFeedUseCase @Inject constructor(
private val userRepo: UserRepository,
private val feedRepo: FeedRepository,
private val settingsRepo: SettingsRepository
) {
operator fun invoke(userId: String): Flow<UserFeed> = combine(
feedRepo.getFeed(userId),
settingsRepo.getContentFilters()
) { feed, filters ->
feed.filter { filters.allows(it) }
}
}
That's a real UseCase: combines three sources, applies filtering.
Multi-module: When Monolith Hinders
For a small application, three packages in one module is enough. For a large project (5+ features, multiple teams), transition to multi-module:
:core:domain
:core:data
:feature:profile:domain (optional)
:feature:profile:presentation
:feature:feed:presentation
:app
Multi-module speeds up incremental builds: a change in :feature:profile doesn't rebuild :feature:feed. Gradle api vs implementation between modules is a separate configuration topic.
Hilt + Clean Architecture
Hilt generates a Dagger graph from annotations. @HiltAndroidApp on Application, @AndroidEntryPoint on Activity/Fragment, @HiltViewModel on ViewModel. Bindings between Domain interfaces and Data implementations:
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}
Scope errors are detected at compile time, not runtime.
Testing by Layers
| Layer | Tools | Android Dependency |
|---|---|---|
| Domain UseCase | JUnit 5 + Mockk | No |
| Data Repository | JUnit 5 + Mockk + MockWebServer | No (minimal with Room) |
| ViewModel | Turbine + Coroutines Test | No |
| UI | Espresso / Compose UI Test | Yes (emulator/device) |
Most tests run on JVM — fast and cheap.
Common Implementation Mistakes
Domain models with @Entity or @SerialName. This is a Data layer leak into Domain. Separate DTO, separate mapper.
UseCase with Context. Context is an Android dependency. UseCase in Domain shouldn't know about it. For string resources — use abstraction StringProvider in Domain with implementation in Presentation.
Flow in Domain with Android types. LiveData in Domain violates boundaries. Only kotlinx.coroutines.flow.Flow.
What We Do During Setup
Design modular structure for project size. Configure Hilt with proper scopes. Implement first feature module as example: UseCase + Repository + ViewModel + tests for all layers. Write Gradle convention plugins for uniform configuration across modules.
Timeline
Setup from scratch (single-module project): 3–5 days. Multi-module from scratch: 1–2 weeks. Migration of existing monolith: 3–8 weeks depending on scope. Cost calculated after audit.







