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
| Pattern | Complexity | Best For | Weakness |
|---|---|---|---|
| MVC | Low | Prototypes, small apps (< 20 screens) | View controllers become massive |
| MVVM | Medium | Medium apps, teams familiar with reactive | ViewModels can become god objects |
| MVI / Redux | Medium-High | Complex state, predictable UI | Boilerplate, steep learning curve |
| Clean Architecture | High | Large teams, long-lived apps, multi-platform | Over-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
| Principle | Why It Matters |
|---|---|
| Single source of truth | UI state lives in one place (ViewModel), preventing inconsistencies |
| Unidirectional data flow | Data flows View → ViewModel → Repository → ViewModel → View |
| Immutable state | State is replaced, not mutated — prevents race conditions |
| Side effects are explicit | Network 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
Navigation Patterns
| Pattern | When to Use | Example |
|---|---|---|
| Coordinator / Router | Complex flows with branching logic | Onboarding → conditionally show different screens |
| Navigation graph | Standard screen-to-screen navigation | Jetpack Navigation, SwiftUI NavigationStack |
| Deep linking | Opening specific screens from external sources | Push 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)