Unlearning MVC: Why modern Android apps need a scalable architecture?
“Each time I kill one bug, two more take its place.”
Sound familiar? Then the chances are that you already work on an MVC-style Android app—the Activity is the God object, XML handles “views,” and the rest is just glue.
It’s where most of us started. As the applications grow—more screens, more state, more async streams—this structure fails under its own weight.
Let’s take some time to understand why old patterns do not work and how the new Android architecture (MVVM/MVI + Compose + Clean principles) will make your app scalable, testable, and maintainable.
The Challenge with the Classic MVC on Android
In the early times, Android developers adopted some loose Model–View–Controller pattern style:
- Fragments/Activity = Controller
- XML Layouts = View
- Repository / DB / API = Model
Sounds clean—until you notice that your Controller is performing network calls, database queries, UI refreshes, error handling, and navigating… all in the same file.
Here’s what it looks like
class LoginActivity : AppCompatActivity() {
private lateinit var api: Api
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
findViewById<Button>(R.id.btnLogin).setOnClickListener {
val email = findViewById<EditText>(R.id.email).text.toString()
val pass = findViewById<EditText>(R.id.password).text.toString()
findViewById<ProgressBar>(R.id.loading).visibility = View.VISIBLE
api.login(email, pass, object : Callback<User> {
override fun onSuccess(user: User) {
findViewById<ProgressBar>(R.id.loading).visibility = View.GONE
startActivity(Intent(this@LoginActivity, HomeActivity::class.java))
}
override fun onError(e: Throwable) {
findViewById<ProgressBar>(R.id.loading).visibility = View.GONE
Toast.makeText(this@LoginActivity, e.message, Toast.LENGTH_SHORT).show()
}
})
}
}
}
This program compiles, but:
- Logic has strong relevance to the UI life cycle
- Not quite a straightforward way to test login logic alone
- Difficult to reuse logic between fragments/screens
- Each UI action alters the view by mutating it → state is ubiquitous
This is fine on a 2-screen app, but not on anything professional.
The Modern Android Architecture Mindset
Android programming today obeys these principles:
- Single Source of Truth → UI reads from a state object, not scattered variables
- Unidirectional Data Flow (UDF) → events go down, state comes up
- Separation of Concerns → each layer has one job
- Declarative UI (Compose) → UI = function(state)
This also leads to MVVM (Model–View–ViewModel) and MVI (Model.
Let’s recreate the same login functionality with a more modern and scalable way.
Step 1: Define the UI State and Intent
State should be immutable and describe what the UI looks like right now.
data class LoginUiState(
val email: String = "",
val password: String = "",
val loading: Boolean = false,
val error: String? = null
)
sealed interface LoginIntent {
data class EmailChanged(val value: String) : LoginIntent
data class PasswordChanged(val value: String) : LoginIntent
data object Submit : LoginIntent
}
This is pure data — no Android imports, no logic.
It’s testable, serializable, and easy to preview in Compose.
Step 2: Create the ViewModel (the brain)
The ViewModel is responsible for:
- Updating the state
- Handling intents
- Calling repositories
- Emitting navigation or toast events
@HiltViewModel
class LoginViewModel @Inject constructor(
private val repo: AuthRepository
) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state.asStateFlow()
fun onIntent(intent: LoginIntent) = when (intent) {
is LoginIntent.EmailChanged -> _state.update { it.copy(email = intent.value) }
is LoginIntent.PasswordChanged -> _state.update { it.copy(password = intent.value) }
LoginIntent.Submit -> submit()
}
private fun submit() = viewModelScope.launch {
val s = _state.value
_state.update { it.copy(loading = true, error = null) }
runCatching { repo.login(s.email, s.password) }
.onSuccess { /* navigate to home (emit event) */ }
.onFailure { e -> _state.update { it.copy(loading = false, error = e.message) } }
}
}
👉 Notice:
- The ViewModel owns the
state(immutable to UI) - We use
update {}to derive new state versions - Errors are stored as part of the state, not shown directly in the UI
- Navigation will be emitted as an effect (we’ll cover that in the next article)
Step 3: The Compose UI Layer
Compose makes binding the state to UI incredibly clean:
@Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
onSuccess: () -> Unit = {}
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(Modifier.padding(16.dp)) {
OutlinedTextField(
value = state.email,
onValueChange = { viewModel.onIntent(LoginIntent.EmailChanged(it)) },
label = { Text("Email") }
)
OutlinedTextField(
value = state.password,
onValueChange = { viewModel.onIntent(LoginIntent.PasswordChanged(it)) },
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation()
)
if (state.loading) LinearProgressIndicator(Modifier.fillMaxWidth())
state.error?.let { Text(it, color = Color.Red) }
Button(
onClick = { viewModel.onIntent(LoginIntent.Submit) },
enabled = !state.loading
) {
Text("Sign In")
}
}
}
That’s it. The UI just renders the current state and dispatches user intents.
No callbacks, no race conditions, no “half-updated” UI.
Step 4: The Unidirectional Data Flow
Here’s how data moves now:
User Action → Intent → ViewModel → Repository → New State → UI
The UI only observes. It never mutates data directly.
This makes your logic predictable and debuggable — you can literally log every state transition like Redux.
Why This Scales Better
| Architectural Concern | Legacy (MVC) | Modern (MVVM/MVI + Jetpack Compose) |
| UI Logic | Mixed within the Activity/Fragment | Pure function of the UI state |
| Testing | Difficult and tightly coupled | Easy (via mocked repositories) |
| State Management | Scattered across mutable variables | Centralized within an immutable object |
| Lifecycle Management | Requires manual handling | Automatically managed by Compose |
| Code Reusability | Low | High (utilizing pure ViewModels) |
When you add more features — caching, error handling, analytics — your ViewModel just composes them.
Your UI code doesn’t grow; it stays declarative.
Real-World Benefits
After refactoring a legacy project at my last company, we saw:
- 30% fewer crash reports (no null-lifecycle access)
- 2x faster feature delivery (new devs onboarded easier)
- Simplified testing — ViewModels tested in isolation
- Composable previews are working without an app context
Architecture isn’t about buzzwords — it’s about mental clarity for your team.
Migration Tips
- Don’t rewrite everything—refactor screen by screen
- Start with new features in Compose + MVVM
- Use adapters to connect old XML screens if needed
- Keep your ViewModel logic UI-agnostic (easy to reuse later in Compose Multiplatform)
Wrapping Up
Old-school MVC served us well, but Android apps today demand more structure.
With MVVM/MVI + Compose, you get:
- Predictable UI state
- Easier testing
- Scalable modular design
- Happier developers 😄
In the next article, we’ll dive deeper into MVI and explore how to manage state + one-time events (effects) without breaking Compose’s reactive model.
TL;DR
- MVC falls apart at scale because UI + logic are tangled
- Modern Android = state-driven, unidirectional flow
Compose + ViewModel + StateFlow = simplicity, stability, and scalability