Five years ago I read Uncle Bob's Clean Architecture book and immediately went and applied everything to a todo app. The result was 12 layers, 40 files, and a working delete-todo button that required touching 7 of them.
Since then I've shipped Clean Architecture in production across 10+ apps — social networks, news apps, streaming platforms, celebrity engagement apps. Here's the honest assessment: where it genuinely earns its complexity, and where I'd push back if someone proposed it today.
What Clean Architecture Actually Gives You
Before discussing trade-offs, let's be clear about what you get:
- Testable business logic. Your use cases have zero Android dependency. You can test them with pure JUnit, no Robolectric, no emulator.
- Swappable data sources. Your domain layer doesn't know if data comes from Room, Retrofit, or a JSON file. This matters when you're migrating APIs or adding offline support.
- Parallel team development. When feature teams own specific use cases and repositories, merge conflicts drop dramatically.
- Enforced boundaries. It's hard to accidentally mix Firebase calls with UI logic when the architecture physically separates them.
The main benefit of Clean Architecture isn't cleanliness — it's the explicit dependency rule. Data flows inward, dependencies point inward. Once you internalize this, architectural decisions become obvious.
The Standard Layer Setup I Use
In every project I follow this structure, with one module per layer for large apps:
// Domain layer — zero Android dependencies
data class User(val id: String, val name: String, val avatarUrl: String)
interface UserRepository {
suspend fun getUser(id: String): Result<User>
fun observeCurrentUser(): Flow<User?>
}
class GetUserUseCase @Inject constructor(
private val userRepository: UserRepository
) {
suspend operator fun invoke(userId: String): Result<User> =
userRepository.getUser(userId)
}
// Data layer — implements the domain contract
class UserRepositoryImpl @Inject constructor(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource,
private val mapper: UserMapper
) : UserRepository {
override suspend fun getUser(id: String): Result<User> =
localDataSource.getUser(id)?.let { Result.success(mapper.map(it)) }
?: remoteDataSource.getUser(id).map { mapper.map(it) }
.also { if (it.isSuccess) localDataSource.saveUser(mapper.mapToLocal(it.getOrThrow())) }
}
Where I've Seen It Go Wrong
1. One use case per UI action, regardless of complexity
I've seen codebases with GetUserNameUseCase, GetUserAvatarUseCase, and GetUserEmailUseCase — each wrapping a single repository call that returns one field of the same User object. This is cargo culting, not architecture.
Use cases should encapsulate meaningful business rules — not be thin wrappers around repository calls. If your use case body is return repository.getSomething(id), ask if it's earning its existence.
A use case that only delegates without adding logic is noise. Merge it into the ViewModel or combine it with a related use case that does have real business logic.
2. Mapper explosion
Three data classes for one entity — UserDto, UserEntity, User — plus two mappers. For simple apps, this is pure overhead. For a User that appears on every screen and maps identically everywhere, consider using the same class across layers and only introducing a separate model when the shapes diverge meaningfully.
3. Modules for everything
Multi-module is valuable at scale, but I've seen solo developers split a 3-screen app into 8 modules because Clean Architecture "requires it." It doesn't. Start with packages, not modules. Migrate to modules when:
- Build times are hurting due to unnecessary recompilation
- You have multiple teams and need clear ownership boundaries
- You're building a dynamic feature that needs to be downloaded separately
How I Evolve It Over Time
I start every project with Clean Architecture at the package level, not the module level:
com.bytevikas.app
├── data/
│ ├── remote/
│ ├── local/
│ └── repository/
├── domain/
│ ├── model/
│ ├── repository/ ← interfaces only
│ └── usecase/
└── presentation/
├── feature_home/
└── feature_profile/
When a feature starts showing complexity — multiple data sources, non-trivial business rules, a team of >1 working on it — that's when I extract it into its own module. The architecture is the same, it just moves to a separate Gradle module.
The Rule I Never Break
Domain layer must have zero Android imports. If I see import android.* anywhere in the domain package, it's a red flag. The domain model is pure Kotlin. It knows nothing about Contexts, Intents, Bundles, or ViewModels. This single constraint enforces everything else.
Bottom Line
Clean Architecture is the right default for any app that:
- Will live longer than 6 months
- Has more than one developer
- Has non-trivial business logic beyond CRUD
- Needs offline support
It's overkill for a hackathon prototype, a personal utility app, or an MVP you're planning to throw away. The cost is real — more files, more boilerplate, steeper onboarding curve. Make sure the benefits justify the overhead for your specific project before committing.
After 5 years, I still choose it for almost everything. But I apply it with pragmatism, not dogma.
No comments yet. Be the first to leave one!