Why Most Clean Architecture Implementations Fail
Clean Architecture is the most over-discussed and under-practiced pattern in Android development. Every team claims to follow it, but most implementations fall into one of two traps: either the architecture is so simplified that it provides no real benefit, or it is so over-engineered that it slows down development without proportional gains.
The problem is not with Robert Martin's original concepts. The problem is with how we translate them into Android's specific constraints — Activity lifecycles, configuration changes, dependency injection scoping, and the constant tension between reactive data flows and imperative UI updates.
The Three-Layer Contract
In production, I enforce a strict three-layer separation with clear dependency rules:
- Presentation Layer — ViewModels, UI state holders, and Compose/XML views. Knows about the Domain layer. Never imports anything from Data.
- Domain Layer — Use cases, entities, and repository interfaces. Knows about nothing. Pure Kotlin, zero Android dependencies.
- Data Layer — Repository implementations, API services, database DAOs, mappers. Knows about Domain (implements its interfaces) but never about Presentation.
The critical rule: the Domain layer has no dependencies. Not on Retrofit. Not on Room. Not on Android framework classes. If your use case imports android.content.Context, your architecture is broken.
Use Cases: The Misunderstood Middle Layer
The most common question I get from junior developers: "Do I really need a use case that just calls the repository?" The answer is yes, but not for the reason you think.
Use cases are not about adding logic today. They are about creating a stable API surface that your ViewModel depends on. When business requirements change — and they always do — you add the new logic inside the use case without touching the ViewModel or the Repository. The use case is a firewall against change propagation.
A use case that "just calls the repository" today is a use case that will contain critical business logic in three months. The cost of creating it now is one class. The cost of adding it later is a refactor.
Practical Implementation
Dependency Injection Setup
With Dagger-Hilt, each layer gets its own module. The Domain module provides use case bindings. The Data module provides repository implementations and binds them to Domain interfaces. The Presentation module is scoped to ViewModel lifecycles.
This scoping means that when a feature module is removed, its entire dependency subtree disappears cleanly — no orphaned bindings, no runtime crashes from missing providers.
Error Handling Across Layers
Each layer has its own error type. The Data layer throws DataException (network errors, parse failures, database conflicts). The Domain layer translates these into DomainError sealed classes that represent business-meaningful failures. The Presentation layer maps DomainError to user-visible messages.
This prevents UI code from containing network error string parsing — a pattern I have seen in too many production codebases.
Results in Practice
After migrating our team's primary app to this structure, we measured:
- 25% reduction in critical bugs — most bugs were previously caused by tangled data/presentation logic
- New feature development velocity increased as developers could work on layers independently
- Unit test coverage for business logic jumped from 15% to 68% — pure Kotlin domain layers are trivial to test
- Onboarding time for new team members dropped because the architecture made the codebase predictable
When Not to Use It
Clean Architecture is not a universal answer. For a simple CRUD app with three screens and no complex business logic, it is genuine over-engineering. Use it when your app has business rules that exist independently of the UI, when multiple data sources need coordination, or when your team is larger than two developers.