Mobile App Architecture Patterns
Choose and implement the right architecture pattern for your mobile application. Covers MVC, MVVM, Clean Architecture, Coordinator pattern, dependency injection, and how architecture choices affect testability, maintainability, and team velocity.
Mobile app architecture determines how maintainable your codebase will be at 50,000 lines, 100,000 lines, and beyond. The right architecture separates concerns cleanly, makes testing straightforward, and allows multiple developers to work in parallel without conflicts.
Architecture Comparison
| Pattern | Testability | Complexity | Best For |
|---|---|---|---|
| MVC | Low | Low | Prototypes, simple apps |
| MVP | Medium | Medium | Forms-heavy apps |
| MVVM | High | Medium | Data-driven UIs |
| Clean Architecture | Very High | High | Large-scale, long-lived apps |
| MVI/Redux | Very High | High | Complex state management |
MVVM (Model-View-ViewModel)
The most popular production architecture for iOS and Android:
View (SwiftUI/Compose)
↓ Observes
ViewModel (Business Logic)
↓ Calls
Repository (Data Abstraction)
↓ Fetches
Data Sources (API, Database, Cache)
iOS (SwiftUI)
class OrderListViewModel: ObservableObject {
@Published var orders: [Order] = []
@Published var isLoading = false
@Published var error: String?
private let repository: OrderRepository
init(repository: OrderRepository = OrderRepositoryImpl()) {
self.repository = repository
}
@MainActor
func loadOrders() async {
isLoading = true
error = nil
do {
orders = try await repository.getOrders()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
struct OrderListView: View {
@StateObject private var viewModel = OrderListViewModel()
var body: some View {
List(viewModel.orders) { order in
OrderRow(order: order)
}
.overlay {
if viewModel.isLoading { ProgressView() }
}
.task { await viewModel.loadOrders() }
}
}
Android (Jetpack Compose)
class OrderListViewModel(
private val repository: OrderRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<OrderListState>(OrderListState.Loading)
val uiState: StateFlow<OrderListState> = _uiState.asStateFlow()
init { loadOrders() }
private fun loadOrders() {
viewModelScope.launch {
_uiState.value = OrderListState.Loading
repository.getOrders()
.onSuccess { orders ->
_uiState.value = OrderListState.Success(orders)
}
.onFailure { error ->
_uiState.value = OrderListState.Error(error.message)
}
}
}
}
sealed class OrderListState {
object Loading : OrderListState()
data class Success(val orders: List<Order>) : OrderListState()
data class Error(val message: String?) : OrderListState()
}
Clean Architecture
Presentation Layer (UI + ViewModels)
↓ depends on
Domain Layer (Use Cases + Entities)
↓ depends on
Data Layer (Repositories + Data Sources)
Key rule: Dependencies point inward.
- Domain knows nothing about Presentation or Data.
- Data implements interfaces defined by Domain.
Use Case Pattern
class GetOrdersUseCase(
private val orderRepository: OrderRepository,
private val userRepository: UserRepository
) {
suspend operator fun invoke(): Result<List<OrderWithUser>> {
val user = userRepository.getCurrentUser() ?: return Result.failure(NotLoggedInError())
val orders = orderRepository.getOrdersForUser(user.id)
return Result.success(orders.map { OrderWithUser(it, user) })
}
}
Repository Pattern
interface OrderRepository {
suspend fun getOrders(): Result<List<Order>>
suspend fun getOrder(id: String): Result<Order>
suspend fun createOrder(request: CreateOrderRequest): Result<Order>
}
class OrderRepositoryImpl(
private val api: OrderApi,
private val db: OrderDao,
private val cache: OrderCache
) : OrderRepository {
override suspend fun getOrders(): Result<List<Order>> {
// Cache-first strategy
cache.getOrders()?.let { return Result.success(it) }
return try {
val orders = api.getOrders()
db.insertAll(orders)
cache.setOrders(orders)
Result.success(orders)
} catch (e: Exception) {
// Fall back to local database
val localOrders = db.getAllOrders()
if (localOrders.isNotEmpty()) Result.success(localOrders)
else Result.failure(e)
}
}
}
Navigation (Coordinator Pattern)
// Separate navigation logic from views
class OrderCoordinator: Coordinator {
var navigationController: UINavigationController
func start() {
let viewModel = OrderListViewModel()
viewModel.onOrderSelected = { [weak self] order in
self?.showOrderDetail(order)
}
let view = OrderListView(viewModel: viewModel)
navigationController.push(view)
}
private func showOrderDetail(_ order: Order) {
let viewModel = OrderDetailViewModel(order: order)
viewModel.onPaymentTapped = { [weak self] in
self?.showPayment(for: order)
}
let view = OrderDetailView(viewModel: viewModel)
navigationController.push(view)
}
}
Anti-Patterns
| Anti-Pattern | Consequence | Fix |
|---|---|---|
| Massive ViewController/Activity | Untestable, merge conflicts | Extract to ViewModel + Use Cases |
| Network calls in View layer | Cannot test UI logic | Repository pattern, dependency injection |
| God ViewModel (1000+ lines) | Same problem as fat controllers | Split into focused ViewModels |
| Skipping domain layer | Business logic in ViewModels | Use Cases encapsulate business rules |
| Tight coupling to frameworks | Cannot unit test | Depend on abstractions, inject dependencies |
Mobile architecture is not about following a pattern perfectly — it is about creating boundaries that let you test, maintain, and evolve the codebase as the team and feature set grow.