I've shipped 25+ production Android apps over the past 8 years, and I can tell you with absolute certainty: MVVM alone won't cut it when your codebase grows beyond 50K lines of code.
Don't get me wrong—MVVM is solid for medium-sized projects. But when I was leading a squad of 4 engineers at Raybit, juggling multiple feature teams working on the same codebase, we hit a wall. Circular dependencies, tangled business logic, and endless merge conflicts became the norm. That's when I started exploring advanced Android architecture patterns that go beyond the standard ViewModel + Repository approach.
In this post, I'm sharing what I've learned about designing scalable Android development systems—patterns I've actually used in production, not theoretical fluff.
Why MVVM Falls Short in Complex Apps
MVVM works beautifully when:
- Your team is small (1-3 engineers)
- Features don't share complex business logic
- State management is relatively straightforward
- You're building a greenfield project with clear requirements
But here's where it breaks down:
"MVVM doesn't enforce boundaries between features. When 4 engineers are working on the same codebase, everyone's ViewModel ends up calling everyone else's Repository. Before you know it, you've got a distributed monolith disguised as clean architecture."
At CodeBrew, we had 6 production apps on the Play Store, and the largest one (4.5+ stars, 100K+ daily active users) suffered from this exact problem. Business logic was scattered across multiple ViewModels. Testing was nightmarish because everything depended on everything else. Adding a simple feature required touching 8-10 files across different layers.
That's when I realized: MVVM is a presentation layer pattern, not an architecture pattern. It doesn't solve cross-cutting concerns like:
- How different features interact
- Where orchestration logic lives
- How to enforce strict dependency flows
- Scaling business logic across multiple screens
VIPER Architecture for Large Teams
VIPER (View-Interactor-Presenter-Entity-Router) is overkill for startups, but it's incredibly powerful for teams building enterprise Android apps.
Here's the structure:
- View — Passive UI (Fragment or Composable)
- Interactor — Fetches data, contains business rules
- Presenter — Stateless logic that translates Interactor output to UI state
- Entity — Domain models (pure data classes)
- Router (Wireframe) — Navigation and screen transitions
The key advantage? Each screen is a self-contained module. Dependencies flow strictly downward. Your Interactor never knows about your Presenter. Your Router is the only thing that knows how to navigate away. This eliminates the circular dependency nightmare I faced earlier.
At Raybit, we implemented a hybrid VIPER + Clean Architecture approach for our EmpSuite ERP platform. Each feature module had its own VIPER structure with a clear Dependency Injection boundary using Hilt. The result? Our 4-person squad could work on completely separate features without stepping on each other's toes.
// VIPER Structure for a Login Feature
// Entity - Pure domain model
data class User(
val id: String,
val email: String,
val token: String
)
// Interactor - Business rules
class LoginInteractor(
private val authRepository: AuthRepository,
private val userPreferences: UserPreferences
) {
suspend fun authenticate(email: String, password: String): Result<User> {
return try {
val response = authRepository.login(email, password)
userPreferences.saveToken(response.token)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
}
// Presenter - Stateless transformation
class LoginPresenter {
fun presentUser(user: User): LoginUIState {
return LoginUIState.Success(user.email)
}
fun presentError(exception: Exception): LoginUIState {
return LoginUIState.Error(exception.message ?: "Unknown error")
}
}
// Router - Navigation logic
class LoginRouter(private val navController: NavController) {
fun navigateToHome() {
navController.navigate("home") {
popUpTo("login") { inclusive = true }
}
}
}
// ViewModel - Orchestrates everything
class LoginViewModel(
private val interactor: LoginInteractor,
private val presenter: LoginPresenter,
private val router: LoginRouter
) : ViewModel() {
private val _uiState = MutableStateFlow<LoginUIState>(LoginUIState.Idle)
val uiState: StateFlow<LoginUIState> = _uiState.asStateFlow()
fun login(email: String, password: String) {
viewModelScope.launch {
_uiState.value = LoginUIState.Loading
val result = interactor.authenticate(email, password)
_uiState.value = result.fold(
onSuccess = { user ->
presenter.presentUser(user).also { router.navigateToHome() }
},
onFailure = { error ->
presenter.presentError(error)
}
)
}
}
}Notice how each layer has a single responsibility. The Interactor doesn't know about UI. The Presenter doesn't fetch data. The Router doesn't understand business logic. This separation makes testing trivial—you can unit test each component in isolation.
MVI: Unidirectional Data Flow in Android
MVI (Model-View-Intent) takes Android architecture in a completely different direction. Instead of the traditional layered approach, it embraces unidirectional data flow borrowed from Redux and The Elm Architecture.
The flow is simple and rigid:
User Action → Intent → Model → State → View
Every state change flows through the same pipeline. No side channels. No back-edges. This predictability is powerful.
I used MVI heavily when building AudioBook AI (50K+ users). Managing audio playback state across multiple screens was a nightmare with traditional MVVM. With MVI, every action—pause, skip, download, etc.—produced a predictable state mutation. Debugging became straightforward: just replay the intent sequence.
- Model — The source of truth (immutable state)
- Intent — User actions or system events
- Reducer — Transforms (State, Intent) → NewState
- Effect — Side effects triggered by state changes
// MVI Pattern with Jetpack Compose
// State - Immutable, single source of truth
data class AudioPlayerState(
val isPlaying: Boolean = false,
val currentPosition: Long = 0L,
val duration: Long = 0L,
val bookTitle: String = "",
val error: String? = null
)
// Intent - User or system actions
sealed class AudioPlayerIntent {
object PlayPressed : AudioPlayerIntent()
object PausePressed : AudioPlayerIntent()
data class SeekTo(val position: Long) : AudioPlayerIntent()
data class LoadBook(val bookId: String) : AudioPlayerIntent()
}
// Reducer - Pure function
class AudioPlayerReducer {
fun reduce(state: AudioPlayerState, intent: AudioPlayerIntent): AudioPlayerState {
return when (intent) {
is AudioPlayerIntent.PlayPressed -> state.copy(isPlaying = true)
is AudioPlayerIntent.PausePressed -> state.copy(isPlaying = false)
is AudioPlayerIntent.SeekTo -> state.copy(currentPosition = intent.position)
is AudioPlayerIntent.LoadBook -> state.copy(bookTitle = intent.bookId)
}
}
}
// ViewModel using MVI
class AudioPlayerViewModel(
private val reducer: AudioPlayerReducer,
private val audioRepository: AudioRepository
) : ViewModel() {
private val _state = MutableStateFlow(AudioPlayerState())
val state: StateFlow<AudioPlayerState> = _state.asStateFlow()
fun processIntent(intent: AudioPlayerIntent) {
val currentState = _state.value
val newState = reducer.reduce(currentState, intent)
_state.value = newState
// Handle side effects
when (intent) {
is AudioPlayerIntent.PlayPressed -> {
viewModelScope.launch {
audioRepository.play(currentState.bookTitle)
}
}
is AudioPlayerIntent.LoadBook -> {
viewModelScope.launch {
try {
val book = audioRepository.getBook(intent.bookId)
_state.value = newState.copy(duration = book.duration)
} catch (e: Exception) {
_state.value = newState.copy(error = e.message)
}
}
}
else -> {}
}
}
}The beauty of MVI is that state mutations are predictable and testable. You can record a sequence of intents and replay them to reproduce any bug. This is especially valuable in complex audio/video apps where timing issues are common.
Domain-Driven Design in Android Development
This isn't technically an "architecture pattern" in the traditional sense, but it's how I structure enterprise Android development projects at scale.
Domain-Driven Design (DDD) starts with the business domain—not the technical stack. You organize your codebase around business concepts, not technical layers.
For Nova Cabs (ride-hailing app), instead of organizing code like this:
app/
├── presentation/
├── data/
├── domain/
We organized it like this:
app/
├── booking/
│ ├── presentation/
│ ├── data/
│ ├── domain/
├── driver/
│ ├── presentation/
│ ├── data/
│ ├── domain/
├── payments/
│ ├── presentation/
│ ├── data/
│ ├── domain/
Each domain (Booking, Driver, Payments) is a self-contained feature module with its own presentation, data, and domain layers. Dependencies only flow from outer domains to core domains—never the reverse.
This approach is powerful because:
- Teams own complete domains end-to-end
- Domain logic is naturally cohesive
- It's easy to extract a domain into a separate microservice later
- New engineers understand the structure immediately
Choosing the Right Pattern for Your Project
Here's my honest recommendation based on real-world experience:
- Small startup (1-3 engineers) — MVVM + Repository pattern. Simple, proven, fast to ship.
- Growing team (4-8 engineers) — VIPER or Domain-Driven Design. Enforce clear boundaries before technical debt becomes unmanageable.
- Complex state management (audio, video, real-time) — MVI pattern. The unidirectional flow pays dividends when debugging.
- Enterprise app (10+ engineers) — Combine DDD + VIPER + Jetpack Compose. Modular, scalable, team-friendly.
The mistake I see most engineers make is adopting a complex pattern too early. VIPER on a 5K line codebase is overengineering. But MVVM on a 200K line enterprise app is underengineering. Match the pattern to your problem space.
📖 Pro Tip
Start with MVVM + Repository. When you hit pain points (circular dependencies, untestable code, merge conflicts), migrate gradually to a more sophisticated pattern. Don't rewrite everything at once.
Key Takeaways
- MVVM is a presentation pattern, not an architecture pattern. It doesn't solve cross-feature orchestration or large-scale code organization.
- VIPER enforces strict separation of concerns and works beautifully for large teams building modular Android apps with Kotlin.
- MVI's unidirectional data flow makes complex state management (audio, real-time, animations) predictable and testable.
- Domain-Driven Design organizes code around business concepts, not technical layers—perfect for enterprise Android development at scale.
- Match your architecture to your team size and problem complexity. Over-engineer early and you'll move slowly. Under-engineer and you'll drown in technical debt.