ESC
Type to search guides, tutorials, and reference documentation.
Verified by Garnet Grid

Mobile App Architecture Patterns: Choosing the Right Foundation

Evaluate mobile architecture patterns — MVC, MVVM, MVI, Clean Architecture — and choose the right one for your team size and app complexity. Covers state management, dependency injection, navigation patterns, and the architecture decisions that prevent complete rewrites.

The architecture of a mobile app determines how quickly you can ship features 18 months from now — not today. Today, any architecture works because the app is small. The architecture question really is: “When this app has 200 screens, 15 engineers, and business logic that has changed direction 4 times, will the codebase still be navigable?”

This guide covers the architecture patterns that scale — and the ones that feel productive early but collapse under real complexity.


Architecture Comparison

PatternComplexityBest ForWeakness
MVCLowPrototypes, small apps (< 20 screens)View controllers become massive
MVVMMediumMedium apps, teams familiar with reactiveViewModels can become god objects
MVI / ReduxMedium-HighComplex state, predictable UIBoilerplate, steep learning curve
Clean ArchitectureHighLarge teams, long-lived apps, multi-platformOver-engineering for small apps

When to Use What

Solo developer, < 20 screens:
  → MVVM is the sweet spot. Testable, not over-engineered.

Small team (2-5), 20-50 screens:
  → MVVM + clear module boundaries. Consider Clean Architecture
    for core business logic.

Large team (5+), 50+ screens:
  → Clean Architecture with MVVM presentation layer.
    Feature modules with clear contracts.

Shared business logic (iOS + Android):
  → Clean Architecture with KMP (Kotlin Multiplatform) for
    domain and data layers.

MVVM in Practice

MVVM separates UI (View) from business logic (ViewModel) from data (Model). The key principle: the View observes the ViewModel. The ViewModel never knows about the View.

iOS (Swift + SwiftUI)

// Model
struct User {
    let id: String
    let name: String
    let email: String
}

// ViewModel
@MainActor
class UserProfileViewModel: ObservableObject {
    @Published var state: ViewState = .loading

    enum ViewState {
        case loading
        case loaded(User)
        case error(String)
    }

    private let userRepository: UserRepositoryProtocol

    init(userRepository: UserRepositoryProtocol) {
        self.userRepository = userRepository
    }

    func loadProfile(userId: String) async {
        state = .loading
        do {
            let user = try await userRepository.getUser(id: userId)
            state = .loaded(user)
        } catch {
            state = .error("Failed to load profile: \(error.localizedDescription)")
        }
    }
}

// View
struct UserProfileView: View {
    @StateObject private var viewModel: UserProfileViewModel

    var body: some View {
        Group {
            switch viewModel.state {
            case .loading:
                ProgressView()
            case .loaded(let user):
                VStack {
                    Text(user.name).font(.title)
                    Text(user.email).foregroundColor(.secondary)
                }
            case .error(let message):
                Text(message).foregroundColor(.red)
            }
        }
        .task { await viewModel.loadProfile(userId: "123") }
    }
}

Android (Kotlin + Compose)

// Model
data class User(val id: String, val name: String, val email: String)

// ViewModel
class UserProfileViewModel(
    private val userRepository: UserRepository
) : ViewModel() {

    sealed class ViewState {
        object Loading : ViewState()
        data class Loaded(val user: User) : ViewState()
        data class Error(val message: String) : ViewState()
    }

    private val _state = MutableStateFlow<ViewState>(ViewState.Loading)
    val state: StateFlow<ViewState> = _state.asStateFlow()

    fun loadProfile(userId: String) {
        viewModelScope.launch {
            _state.value = ViewState.Loading
            try {
                val user = userRepository.getUser(userId)
                _state.value = ViewState.Loaded(user)
            } catch (e: Exception) {
                _state.value = ViewState.Error("Failed to load: ${e.message}")
            }
        }
    }
}

// Composable
@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    when (val s = state) {
        is ViewState.Loading -> CircularProgressIndicator()
        is ViewState.Loaded -> Column {
            Text(s.user.name, style = MaterialTheme.typography.headlineMedium)
            Text(s.user.email, color = MaterialTheme.colorScheme.onSurfaceVariant)
        }
        is ViewState.Error -> Text(s.message, color = MaterialTheme.colorScheme.error)
    }

    LaunchedEffect(Unit) { viewModel.loadProfile("123") }
}

State Management Principles

PrincipleWhy It Matters
Single source of truthUI state lives in one place (ViewModel), preventing inconsistencies
Unidirectional data flowData flows View → ViewModel → Repository → ViewModel → View
Immutable stateState is replaced, not mutated — prevents race conditions
Side effects are explicitNetwork calls, database writes happen in defined places
Unidirectional data flow:

  User Action (tap, swipe)


  ViewModel receives intent


  ViewModel calls repository / use case


  ViewModel updates state (immutable)


  View observes state and re-renders

Feature Module Architecture

For large apps, organize code by feature, not by layer:

✅ Feature-based (scales with teams):
app/
├── features/
│   ├── auth/
│   │   ├── data/           # Repository, API, local storage
│   │   ├── domain/         # Use cases, entities
│   │   └── presentation/   # ViewModel, Views/Composables
│   ├── checkout/
│   │   ├── data/
│   │   ├── domain/
│   │   └── presentation/
│   └── profile/
│       ├── data/
│       ├── domain/
│       └── presentation/
├── core/
│   ├── network/            # HTTP client, interceptors
│   ├── database/           # Database setup, migrations
│   ├── di/                 # Dependency injection
│   └── design-system/      # Shared UI components
└── app/
    └── navigation/         # App-level navigation graph
❌ Layer-based (does not scale):
app/
├── models/          # ALL models from every feature mixed together
├── viewmodels/      # ALL viewmodels
├── views/           # ALL views
├── repositories/    # ALL repositories
└── network/         # ALL API calls

PatternWhen to UseExample
Coordinator / RouterComplex flows with branching logicOnboarding → conditionally show different screens
Navigation graphStandard screen-to-screen navigationJetpack Navigation, SwiftUI NavigationStack
Deep linkingOpening specific screens from external sourcesPush notifications, marketing links

Implementation Checklist

  • Choose architecture pattern based on team size and app complexity (MVVM for most)
  • Enforce unidirectional data flow: View → ViewModel → Repository → ViewModel → View
  • Use immutable state objects (sealed classes / enums for view states)
  • Organize code by feature modules, not by layer
  • Define clear module boundaries: features should not directly import other features
  • Implement dependency injection (Hilt for Android, factory pattern for iOS)
  • Create shared core modules for networking, database, and design system
  • Write ViewModels with no platform dependencies (enables unit testing)
  • Handle all error states explicitly in the view state model
  • Document architecture decisions in an ADR (Architecture Decision Record)
Jakub Dimitri Rezayev
Jakub Dimitri Rezayev
Founder & Chief Architect • Garnet Grid Consulting

Jakub holds an M.S. in Customer Intelligence & Analytics and a B.S. in Finance & Computer Science from Pace University. With deep expertise spanning D365 F&O, Azure, Power BI, and AI/ML systems, he architects enterprise solutions that bridge legacy systems and modern technology — and has led multi-million dollar ERP implementations for Fortune 500 supply chains.

View Full Profile →