After 8 years of shipping Android applications — from solo freelance projects to production apps with hundreds of thousands of daily active users — I've watched codebases age in two very different ways. Some remain maintainable, testable, and a joy to extend years after launch. Others become unmaintainable tangles of God Activities, bloated ViewModels, and logic scattered across every layer.
The difference, almost always, comes down to architecture. And while MVVM is a step in the right direction, it's only part of the answer.
📖 What You'll Learn
Why MVVM alone breaks down at scale, how Clean Architecture's three-layer model solves it, a practical Kotlin implementation, and the real-world trade-offs I've encountered in production.
The Problem with MVVM at Scale
Don't get me wrong — MVVM is a massive improvement over MVP and certainly over the Activity-as-everything approach. But here's what happens in real projects:
- Your ViewModel starts taking on data transformation, business rules, and API calls simultaneously
- You need to reuse business logic across two ViewModels — and there's nowhere clean to put it
- Unit testing the ViewModel requires mocking the entire data layer
- A backend API change forces edits in multiple unrelated classes
MVVM tells you where to put your UI state. Clean Architecture tells you where to put everything else.
The Three-Layer Model
Clean Architecture separates your app into three concentric layers. The fundamental rule is the Dependency Rule: dependencies only point inward.
1. Presentation Layer
Contains your Activities, Fragments, Composables, and ViewModels. The ViewModel's only job is to hold UI state and forward user intent to the Domain layer via Use Cases. It should contain zero business logic.
2. Domain Layer
The heart of your application. Contains Use Cases, Domain Models, and Repository interfaces. This layer is pure Kotlin with zero Android dependencies — which means it's trivially unit testable.
3. Data Layer
Contains the concrete implementations of your Repository interfaces. This is where Room databases, Retrofit services, and Firebase calls live. It knows nothing about the Presentation layer.
A Practical Kotlin Implementation
Let's build a concrete example — the user authentication flow for a ride-hailing app like Nova Cabs.
// Domain Layer — pure Kotlin, zero Android imports
interface AuthRepository {
suspend fun login(email: String, password: String): Result<User>
suspend fun logout(): Result<Unit>
fun isLoggedIn(): Flow<Boolean>
}
// Use Case — business logic lives HERE, not in the ViewModel
class LoginUseCase(private val repository: AuthRepository) {
suspend operator fun invoke(email: String, password: String): Result<User> {
if (email.isBlank() || !email.contains("@"))
return Result.failure(InvalidEmailException())
return repository.login(email.trim(), password)
}
}// Presentation Layer — ViewModel only holds state + calls Use Cases
class LoginViewModel(private val loginUseCase: LoginUseCase) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun login(email: String, password: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
loginUseCase(email, password)
.onSuccess { user -> _uiState.update { it.copy(isLoading = false, user = user) } }
.onFailure { err -> _uiState.update { it.copy(isLoading = false, error = err.message) } }
}
}
}⚠️ Common Mistake
Never inject a Repository directly into a ViewModel. Always go through a Use Case. This keeps your ViewModel thin and ensures your business logic is testable in isolation.
The Payoff: Testing Becomes Trivial
Because the Domain layer is pure Kotlin with interface-based dependencies, you can unit test every business rule without Robolectric, without an emulator, and without mocking Android framework classes.
// Pure JUnit test — runs in milliseconds, no Android needed
class LoginUseCaseTest {
private val fakeRepository = FakeAuthRepository()
private val useCase = LoginUseCase(fakeRepository)
@Test
fun loginWithInvalidEmailReturnsFailure() = runTest {
val result = useCase("not-an-email", "password123")
assertTrue(result.isFailure)
assertIs<InvalidEmailException>(result.exceptionOrNull())
}
}Real-World Trade-offs
I'd be doing you a disservice if I didn't address the criticisms:
- Boilerplate: For a simple screen, you're writing an interface, implementation, Use Case, ViewModel, and UI class. For CRUD-heavy enterprise apps like EmpSuite, this pays off immediately.
- Onboarding time: Junior developers need time to understand the Dependency Rule and resist shortcutting through layers.
- Initial velocity: You'll ship the first feature slower. You'll ship the twentieth feature significantly faster.
Conclusion
MVVM is a foundation, not a complete architecture. By adding the Domain layer — with Use Cases that encapsulate business logic and Repository interfaces that abstract data sources — you get an application that's testable, maintainable, and genuinely pleasant to extend.
After shipping this pattern across ride-hailing apps, ERP platforms, and healthcare systems, I can tell you: the codebases that age best are the ones where you can open any file and immediately understand its single responsibility.