Ali Mansour
Ali Mansour
Ali Mansour
Ali Mansour
Ali Mansour

Senior Software Engineer

Senior Mobile Engineer

Content Creator

Tech Speaker

Unlearning MVC: Why modern Android apps need a scalable architecture?

October 19, 2025 Code
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 ConcernLegacy (MVC)Modern (MVVM/MVI + Jetpack Compose)
UI LogicMixed within the Activity/FragmentPure function of the UI state
TestingDifficult and tightly coupledEasy (via mocked repositories)
State ManagementScattered across mutable variablesCentralized within an immutable object
Lifecycle ManagementRequires manual handlingAutomatically managed by Compose
Code ReusabilityLowHigh (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

  1. Don’t rewrite everything—refactor screen by screen
  2. Start with new features in Compose + MVVM
  3. Use adapters to connect old XML screens if needed
  4. 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

Tags:
Write a comment

Verified by MonsterInsights