Mobile App Architecture Patterns


Mobile app architecture is a topic buried in acronyms - MVC, MVP, MVVM, MVI, Clean Architecture, VIPER - most of which are taught by explaining what the letters stand for, which tells you nothing useful. The letters are labels. The problems they solve are what matter.

Every architecture pattern for mobile applications is trying to answer the same question: how do you separate UI from business logic so that each can change independently, and so that business logic is testable without a running app?

Why architecture matters more on mobile

Mobile platforms have a lifecycle problem that web and backend applications don’t. An Activity or ViewController can be destroyed and recreated by the system at any time - when the user rotates the screen, when memory is needed, when the app is backgrounded. State that lives in the UI layer is lost.

Code that does networking, data processing, and business logic in view controllers (the original iOS pattern) gets destroyed along with the view. The user has to wait for it to reload. Or worse: the view is gone but the callback still fires, and it tries to update UI that no longer exists.

The architectural patterns are all solving this: keep logic in components with a longer or more controlled lifecycle than the UI.

MVC - Model, View, Controller

The original pattern, and the one Apple pushed for iOS for years.

  • Model - data and business rules
  • View - the UI elements
  • Controller - mediates between them

In practice, on iOS, the ViewController did everything. It handled lifecycle, layout, delegation, data loading, business logic, and state management. “Massive View Controller” is the joke, and it’s accurate.

MVC isn’t wrong in theory. The problem is that the platform’s UIViewController class is so heavily responsible for UI lifecycle that it’s almost impossible to keep it thin. Business logic mixed into view controllers is hard to test and impossible to reuse.

Verdict: Fine for simple screens; becomes a maintenance problem at scale. Worth understanding as the baseline.

MVP - Model, View, Presenter

MVP extracts logic into a Presenter that has no dependency on the UI framework. The View is passive - it only does what the Presenter tells it and reports user actions back.

// View interface - no Android imports
interface ProfileView {
    fun showUserName(name: String)
    fun showError(message: String)
    fun showLoading(visible: Boolean)
}

// Presenter - no Android imports, fully testable
class ProfilePresenter(
    private val view: ProfileView,
    private val userRepo: UserRepository
) {
    fun loadProfile(userId: String) {
        view.showLoading(true)
        userRepo.getUser(userId)
            .onSuccess { user ->
                view.showLoading(false)
                view.showUserName(user.name)
            }
            .onFailure { error ->
                view.showLoading(false)
                view.showError(error.message)
            }
    }
}

// Activity/Fragment - only implements the view interface
class ProfileActivity : AppCompatActivity(), ProfileView {
    private val presenter = ProfilePresenter(this, UserRepository())

    override fun showUserName(name: String) { nameTextView.text = name }
    override fun showError(message: String) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() }
    override fun showLoading(visible: Boolean) { progressBar.isVisible = visible }
}

The Presenter has no Android imports. It can be unit tested with a mock View.

The problem: the Presenter holds a reference to the View. When the Activity is destroyed and recreated, the Presenter needs to know about it. Lifecycle management is manual.

MVVM - Model, View, ViewModel

MVVM is now the dominant Android pattern and was adopted by SwiftUI on iOS. The ViewModel exposes observable state. The View observes and re-renders when state changes.

// ViewModel - survives configuration changes on Android
class ProfileViewModel(private val userRepo: UserRepository) : ViewModel() {
    private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
    val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()

    fun loadProfile(userId: String) {
        viewModelScope.launch {
            _uiState.value = ProfileUiState.Loading
            userRepo.getUser(userId)
                .onSuccess { _uiState.value = ProfileUiState.Success(it.name) }
                .onFailure { _uiState.value = ProfileUiState.Error(it.message ?: "Unknown error") }
        }
    }
}

sealed class ProfileUiState {
    object Loading : ProfileUiState()
    data class Success(val name: String) : ProfileUiState()
    data class Error(val message: String) : ProfileUiState()
}

// Composable - observes state
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsState()
    when (state) {
        is ProfileUiState.Loading -> CircularProgressIndicator()
        is ProfileUiState.Success -> Text((state as ProfileUiState.Success).name)
        is ProfileUiState.Error   -> Text((state as ProfileUiState.Error).message)
    }
}

On Android, ViewModel survives rotation. The Composable observes a StateFlow. When the screen is recreated, it resubscribes to the same ViewModel - state is preserved.

MVVM with unidirectional data flow and sealed state classes is the standard modern Android architecture. SwiftUI + @ObservableObject/@Observable is the equivalent on iOS.

The tradeoff: MVVM works well for screen-level state. It doesn’t prescribe how to structure the layer below - data loading, repositories, domain logic. You still have to make those choices.

MVI - Model, View, Intent

MVI makes data flow strictly unidirectional and the state a single immutable object. The user sends an Intent (user action). A Reducer computes new state from the current state and the intent. The View observes state and renders it.

// State: single immutable object
data class ProfileState(
    val isLoading: Boolean = false,
    val userName: String? = null,
    val error: String? = null
)

// Intent: all possible user actions
sealed class ProfileIntent {
    data class LoadProfile(val userId: String) : ProfileIntent()
    object Retry : ProfileIntent()
}

// ViewModel processes intents, emits states
class ProfileViewModel : ViewModel() {
    private val _state = MutableStateFlow(ProfileState())
    val state: StateFlow<ProfileState> = _state.asStateFlow()

    fun processIntent(intent: ProfileIntent) {
        when (intent) {
            is ProfileIntent.LoadProfile -> loadProfile(intent.userId)
            ProfileIntent.Retry -> { /* re-trigger load */ }
        }
    }
}

MVI is strict: state flows in one direction, always. There are no “view bindings” or two-way data flow. Every state change is an explicit, named transition.

This strictness is the value. Bugs in UI state are reproducible: given state S and intent I, the new state is deterministic. You can log every state transition and replay them. Testing is straightforward - feed intents, assert state.

The cost: more boilerplate. Every action is an Intent class. Every state is an immutable data class update. For simple screens, this feels excessive.

What actually matters

The patterns sit on a spectrum of strictness. The more strictly unidirectional and explicit your data flow, the easier it is to debug and test - and the more ceremony it requires.

For most teams:

  • Use MVVM with StateFlow/@Observable for standard screens. It’s the platform recommendation, the ecosystem is built around it, and it’s the pattern your new hires will already know.
  • Add unidirectional flow (MVI-style) for complex screens where state management is getting messy.
  • Separate your ViewModel from your data layer with repositories - the ViewModel shouldn’t know whether data comes from a network call or a database.
  • Keep business logic out of the ViewModel. The ViewModel should orchestrate, not calculate. Business rules belong in use cases or the domain layer.

The pattern you choose matters less than whether you actually follow it consistently. A half-followed MVVM codebase with logic in the wrong layer is harder to work in than a well-structured anything.