Eliminating Spaghetti Code with Clean Architecture Android
An Android project without a clear architecture looks predictable: an Activity with 800 lines, Retrofit interface called directly from onClick, Room DAO returning LiveData<List<User>> straight to a Fragment. It works until the first requirement: "add caching", "write tests", "extract a shared module for Wear OS". Then you discover everything is glued together tightly. Typical for apps that grew without refactoring. Clean Architecture Android solves this through dependency inversion: inner layers don't know about outer ones. Retrofit and Room can be replaced without changing business logic. Our experience shows that a properly set up architecture reduces feature implementation time by 30% compared to monolithic code. Testing effort drops by 50% — most tests run on JVM without an emulator.
How Clean Architecture Separates Layers
Domain — the core of the app. Pure Kotlin with no Android imports. Here you find Entity models, Repository interfaces, UseCase classes. This module compiles as a JVM library and can be tested without an emulator. The absence of Android dependencies is a key advantage.
Data — repository implementations. Retrofit DTOs, Room Entities, DTO-to-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: first return the cache, then update from the server in parallel. If the network fails but the cache exists, the user sees no error.
Presentation — ViewModel, UI (Compose or XML). Depends only on Domain: calls UseCases, receives Flow, transforms into UI state. It doesn't know whether data comes from Room or Retrofit.
Case study: We recently migrated a monolithic food delivery app to Clean Architecture. The original code contained an Activity with 1500 lines mixing HTTP requests, database operations, and UI logic. After separating into layers, we achieved 80% test coverage in two months, and implementing a new feature (adding push notifications for order status) dropped from a week to two days.
When Is a UseCase Needed and When Is It Overkill?
A UseCase is justified when:
- It orchestrates multiple repositories
- It contains non-trivial business rules
- It is reused across multiple ViewModels
GetUserUseCase that only does return userRepository.getUser(id) is an unnecessary layer. If a ViewModel works with a single repository without additional logic — inject the repository directly. This reduces boilerplate and simplifies understanding.
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) } } } This is a true UseCase: it combines three sources and applies filtering.
How Multi-Module Speeds Up Builds
For a small app, three packages in one module is enough. For a large project (5+ features, multiple teams) we switch to multi-module:
:core:domain :core:data :feature:profile:domain (optional) :feature:profile:presentation :feature:feed:presentation :app Multi-module improves incremental builds by 40%: changes in :feature:profile don't trigger a rebuild of :feature:feed. Managing api vs implementation in Gradle between modules is a separate tuning topic.
Hilt + Clean Architecture
Hilt generates the 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 } Wrong scoping errors are caught at compile time, not runtime. For more details, see the Android Architecture Guide.
Testing by Layer
| 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. Clean Architecture makes writing tests 3x faster than in a monolith.
Typical Implementation Problems
- Domain models with
@Entityor@SerialName— data layer leakage. Use separate DTOs and a mapper. - UseCases holding
Context.Contextis an Android dependency. Use an abstraction likeStringProviderin Domain with implementation in Presentation. - Flow in Domain using Android types.
LiveDatain Domain is a violation. Use onlykotlinx.coroutines.flow.Flow.
What's Included in the Setup
| Component | Description |
|---|---|
| Architecture diagram | Layer and dependency graph |
| Hilt configuration | Modules, scopes, bindings |
| Example feature module | UseCase + Repository + ViewModel + tests |
| CI/CD pipeline | Integration with GitHub Actions / GitLab CI |
| Documentation | README with structure overview |
| Team training | 1–2 sessions on Clean Architecture |
Why Choose Us
- 5+ years of Android development experience
- 20+ successfully implemented Clean Architecture projects
- Certified Google engineers
- Quality guarantee: at least 70% test coverage
Timelines and Pricing
Fresh setup (single-module project): 3–5 days. Multi-module from scratch: 1–2 weeks. Migration of an existing monolith: 3–8 weeks depending on scope. We provide an accurate estimate after analyzing your code.
Contact us to discuss your project. We'll assess your current architecture and propose an action plan. Request a free initial audit.
Example multi-module build.gradle.kts configuration
// build.gradle.kts (root)
plugins {
id("com.android.application") version "8.1.0" apply false
}







