Mobile Architecture Patterns
Purpose
Select and implement cross-platform mobile architecture patterns including MVVM, MVI, Clean Architecture, Coordinator, and Repository patterns.
Agent Protocol
Trigger
User request includes: mobile pattern, mobile architecture, mvvm, mvi, clean architecture mobile, coordinator, navigator pattern, mobile project structure, mobile folder structure, mobile di.
Input Context
- Platform (iOS, Android, Flutter, React Native)
- Architecture approach (MVVM, MVI, Clean, TCA)
- DI framework (Hilt, Swinject, GetIt, manual)
Output Artifact
A markdown document containing:
- Architecture pattern selection guide
- Folder structure matching the pattern
- Data flow diagram (text)
- Key code snippets for the chosen pattern
Response Format
No preamble. No postamble. No explanations.
Max Response Length
4096 tokens
Decision Trees
Architecture Pattern Selection
App complexity?
├── Simple CRUD, list/detail, <10 screens
│ └── MVVM + Repository (minimal abstraction, well-known)
├── Complex state (real-time, multi-step, concurrent updates)
│ └── MVI (unidirectional, predictable, sealed intents/state)
├── Large codebase, 5+ teams, shared domain logic
│ └── Clean Architecture (strict boundaries, modular)
└── Swift-only, complex state, composable
└── TCA (The Composable Architecture, SwiftUI-native)
DI Framework Selection
Platform?
├── Android
│ ├── Hilt (standard, Dagger wrapper, @HiltViewModel)
│ └── Koin (lightweight, Kotlin DSL, no annotation processing)
├── iOS
│ ├── Swinject (runtime DI, container, assembly)
│ └── Factory (Swift macros, compile-time safe)
├── Flutter
│ ├── GetIt (service locator, simple, fast)
│ └── Riverpod (declarative, auto-dispose, testable)
└── React Native
├── InversifyJS (decorators, container)
└── Manual context-based DI (React Context + hooks)
Navigation Pattern Selection
Navigation complexity?
├── Simple linear flow (login → home → detail)
│ └── Built-in navigation (NavigationStack, NavHost, Stack)
├── Deep linking, conditional flows, auth state
│ └── Coordinator pattern (navigation extracted from views)
├── Tab-based with nested stacks
│ └── ShellRoute (Flutter), Tab Navigator (RN)
└── Complex onboarding/wizard with branching
└── Coordinator with child coordinators
Workflow
Step 1: Identify Architecture Requirements
Assess team size, app complexity, testing requirements, and platform constraints.
Step 2: Select Core Pattern
Choose MVVM for standard apps, MVI for complex state, Clean Architecture for large multi-team codebases.
Step 3: Define Layer Boundaries
Establish dependency rules: presentation depends on domain, domain is pure business logic, data implements domain interfaces.
Step 4: Separate Navigation
Extract navigation logic into Coordinator/Navigator — views know nothing about other screens.
Step 5: Set Up Dependency Injection
Configure DI framework with explicit scopes and clear module organization per layer.
Cross-Platform Pattern Mapping
| Pattern | iOS | Android | Flutter | React Native |
|---|---|---|---|---|
| MVVM | ObservableObject + @Published | ViewModel + StateFlow | ChangeNotifier + Riverpod | useState + Zustand |
| MVI | Combine + enum Intent | MVI + StateFlow + sealed class | BLoC (Event → Bloc → State) | useReducer + dispatch |
| Clean Arch | SPM modules per layer | Gradle modules | Dart packages | Workspace packages |
| Coordinator | Coordinator protocol + child | NavHost + sealed route | GoRouter ShellRoute | React Navigation nesting |
Rules
- Domain layer must have zero framework dependencies — pure business logic only
- Data layer depends on domain layer — never the reverse
- MVVM: View observes ViewModel state; ViewModel never holds View reference
- MVI: sealed class for intents, single sealed state class, reducer function
- Clean Architecture: outer layers depend on inner layers, never inward
- Coordinator owns navigation — views call coordinator callbacks
- Repository is the single source of truth
- Choose architecture based on app complexity, not trends
MVVM Implementation
Android — Jetpack Compose
// ViewModel
@HiltViewModel
class OrderViewModel @Inject constructor(
private val getOrdersUseCase: GetOrdersUseCase
) : ViewModel() {
private val _state = MutableStateFlow(OrderUiState())
val state: StateFlow<OrderUiState> = _state.asStateFlow()
init { loadOrders() }
fun onAction(action: OrderAction) {
when (action) {
is OrderAction.Refresh -> loadOrders()
is OrderAction.SelectOrder -> selectOrder(action.id)
}
}
private fun loadOrders() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
getOrdersUseCase().fold(
orders ->
_state.update { it.copy(orders = orders, isLoading = false) }
},
error ->
_state.update { it.copy(error = error.message, isLoading = false) }
}
)
}
}
}
data class OrderUiState(
val orders: List<Order> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
sealed interface OrderAction {
data object Refresh : OrderAction
data class SelectOrder(val id: String) : OrderAction
}
// Composable
@Composable
fun OrderScreen(viewModel: OrderViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
OrderContent(state = state,
}
iOS — SwiftUI
// ViewModel
@MainActor
@Observable
final class OrderViewModel {
private let getOrdersUseCase: GetOrdersUseCaseProtocol
var orders: [Order] = []
var isLoading = false
var error: String?
init(getOrdersUseCase: GetOrdersUseCaseProtocol) {
self.getOrdersUseCase = getOrdersUseCase
}
func loadOrders() async {
isLoading = true
do {
orders = try await getOrdersUseCase()
error = nil
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
// View
struct OrderView: View {
@State private var vm: OrderViewModel
var body: some View {
List(vm.orders) { order in
OrderRow(order: order)
}
.overlay { if vm.isLoading { ProgressView() } }
.task { await vm.loadOrders() }
.refreshable { await vm.loadOrders() }
}
}
Flutter — Riverpod
// Provider
@riverpod
class OrderList extends _$OrderList {
@override
Future<List<Order>> build() => _fetchOrders();
Future<List<Order>> _fetchOrders() async {
final repo = ref.read(orderRepositoryProvider);
return repo.getOrders();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => _fetchOrders());
}
}
// View
class OrderScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final ordersAsync = ref.watch(orderListProvider);
return ordersAsync.when(
data: (orders) => ListView.builder(
itemCount: orders.length,
itemBuilder: (_, i) => OrderRow(order: orders[i]),
),
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
);
}
}
React Native — Zustand
// Store
import { create } from 'zustand';
interface OrderState {
orders: Order[];
isLoading: boolean;
error: string | null;
loadOrders: () => Promise<void>;
refresh: () => Promise<void>;
}
export const useOrderStore = create<OrderState>((set) => ({
orders: [],
isLoading: false,
error: null,
loadOrders: async () => {
set({ isLoading: true, error: null });
try {
const orders = await orderService.getOrders();
set({ orders, isLoading: false });
} catch (e) {
set({ error: (e as Error).message, isLoading: false });
}
},
refresh: async () => {
set({ isLoading: true });
try {
const orders = await orderService.getOrders();
set({ orders, isLoading: false });
} catch (e) {
set({ isLoading: false });
}
},
}));
// Component
function OrderScreen() {
const { orders, isLoading, loadOrders } = useOrderStore();
useEffect(() => { loadOrders(); }, []);
return (
<FlatList
data={orders}
renderItem={({ item }) => <OrderRow order={item} />}
refreshing={isLoading}
/>
);
}
MVI Implementation
Android — MVI with StateFlow
// Intent (user actions)
sealed interface OrderIntent {
data object Load : OrderIntent
data class Search(val query: String) : OrderIntent
data class Delete(val id: String) : OrderIntent
}
// State (single source of truth)
data class OrderListState(
val orders: List<Order> = emptyList(),
val searchQuery: String = "",
val isLoading: Boolean = false,
val error: String? = null
)
// Reducer (pure function)
fun OrderListState.reduce(intent: OrderIntent): OrderListState {
return when (intent) {
is OrderIntent.Load -> copy(isLoading = true, error = null)
is OrderIntent.Search -> copy(searchQuery = intent.query)
is OrderIntent.Delete -> copy(
orders = orders.filter { it.id != intent.id }
)
}
}
// ViewModel processes intents, emits states
class OrderListViewModel : ViewModel() {
private val _state = MutableStateFlow(OrderListState())
val state: StateFlow<OrderListState> = _state.asStateFlow()
fun process(intent: OrderIntent) {
_state.update { it.reduce(intent) }
when (intent) {
is OrderIntent.Load -> loadOrders()
is OrderIntent.Search -> searchOrders(intent.query)
is OrderIntent.Delete -> deleteOrder(intent.id)
}
}
}
Flutter — BLoC
// Event
abstract class OrderEvent {}
class LoadOrders extends OrderEvent {}
class SearchOrders extends OrderEvent { final String query; }
// State
abstract class OrderState {}
class OrderInitial extends OrderState {}
class OrderLoading extends OrderState {}
class OrderLoaded extends OrderState { final List<Order> orders; }
class OrderError extends OrderState { final String message; }
// Bloc
class OrderBloc extends Bloc<OrderEvent, OrderState> {
final OrderRepository repo;
OrderBloc(this.repo) : super(OrderInitial()) {
on<LoadOrders>((event, emit) async {
emit(OrderLoading());
try {
final orders = await repo.getOrders();
emit(OrderLoaded(orders: orders));
} catch (e) {
emit(OrderError(message: e.toString()));
}
});
}
}
Clean Architecture
Folder Structure
project/
├── domain/ # Pure business logic — zero framework deps
│ ├── model/
│ │ └── Order.kt
│ ├── repository/ # Interfaces only
│ │ └── OrderRepository.kt
│ └── usecase/
│ ├── GetOrdersUseCase.kt
│ └── CreateOrderUseCase.kt
├── data/ # Implements domain interfaces
│ ├── repository/
│ │ └── OrderRepositoryImpl.kt
│ ├── remote/
│ │ └── OrderApi.kt
│ └── local/
│ ├── OrderDao.kt
│ └── OrderEntity.kt
├── presentation/ # UI framework specific
│ ├── viewmodel/
│ │ └── OrderViewModel.kt
│ ├── screen/
│ │ └── OrderScreen.kt
│ └── navigation/
│ └── OrderNavGraph.kt
└── di/ # Dependency injection modules
├── NetworkModule.kt
├── DatabaseModule.kt
└── RepositoryModule.kt
Dependency Rule
presentation/ → domain/ ← data/
Outer layers depend on inner layers. Inner never knows outer.
Domain has no imports from Android, iOS, or platform SDKs.
UseCase Pattern
// domain/usecase/GetOrdersUseCase.kt
class GetOrdersUseCase(
private val repository: OrderRepository
) {
suspend operator fun invoke(): Result<List<Order>> {
return repository.getOrders()
}
}
// domain/usecase/CreateOrderUseCase.kt
class CreateOrderUseCase(
private val repository: OrderRepository,
private val validator: OrderValidator
) {
suspend operator fun invoke(order: Order): Result<Order> {
if (!validator.validate(order)) {
return Result.failure(ValidationException("Invalid order"))
}
return repository.createOrder(order)
}
}
Coordinator / Navigator Pattern
iOS — Coordinator
protocol Coordinator: AnyObject {
var navigationController: UINavigationController { get }
var childCoordinators: [Coordinator] { get set }
func start()
}
extension Coordinator {
func addChild(_ coordinator: Coordinator) {
childCoordinators.append(coordinator)
}
func removeChild(_ coordinator: Coordinator) {
childCoordinators.removeAll { $0 === coordinator }
}
}
final class AppCoordinator: Coordinator {
let navigationController: UINavigationController
var childCoordinators: [Coordinator] = []
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
let vm = OrderListViewModel(onOrderSelected: { [weak self] id in
self?.showOrderDetail(id)
})
let vc = OrderListViewController(viewModel: vm)
navigationController.pushViewController(vc, animated: false)
}
private func showOrderDetail(_ id: String) {
let child = OrderDetailCoordinator(
navigationController: navigationController,
orderId: id
)
addChild(child)
child.onFinish = { [weak self, weak child] in
guard let child else { return }
self?.removeChild(child)
}
child.start()
}
}
Android — Navigation Compose
sealed class Route(val route: String) {
data object OrderList : Route("orders")
data class OrderDetail(val id: String) : Route("orders/{id}") {
companion object {
const val ROUTE = "orders/{id}"
fun createRoute(id: String) = "orders/$id"
}
}
}
@Composable
fun AppNavGraph(navController: NavHostController = rememberNavController()) {
NavHost(navController, startDestination = Route.OrderList.route) {
composable(Route.OrderList.route) {
OrderListScreen(onOrderClick = { id ->
navController.navigate(Route.OrderDetail.createRoute(id))
})
}
composable(
route = Route.OrderDetail.ROUTE,
arguments = listOf(navArgument("id") { type = NavType.StringType })
) {
OrderDetailScreen()
}
}
}
Flutter — GoRouter
final router = GoRouter(
initialLocation: '/orders',
routes: [
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(
path: '/orders',
builder: (_, __) => const OrderListScreen(),
routes: [
GoRoute(
path: ':id',
builder: (_, state) => OrderDetailScreen(
id: state.pathParameters['id']!,
),
),
],
),
GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()),
],
),
],
);
Repository Pattern
// Repository interface (domain layer)
interface OrderRepository {
fun getOrders(): Flow<List<Order>>
suspend fun refreshOrders()
suspend fun createOrder(order: Order): Order
suspend fun deleteOrder(id: String)
}
// Repository implementation (data layer)
class OrderRepositoryImpl(
private val api: OrderApi,
private val dao: OrderDao,
private val connectivity: ConnectivityMonitor
) : OrderRepository {
override fun getOrders(): Flow<List<Order>> {
return dao.observeAll().map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun refreshOrders() {
if (!connectivity.isOnline()) return
val remote = api.getOrders()
dao.upsertAll(remote.map { it.toEntity() })
}
override suspend fun createOrder(order: Order): Order {
val entity = order.toEntity()
dao.insert(entity)
if (connectivity.isOnline()) {
return api.createOrder(order).also { dao.update(it.toEntity()) }
}
return order // Will sync later
}
override suspend fun deleteOrder(id: String) {
dao.delete(id)
if (connectivity.isOnline()) {
api.deleteOrder(id)
}
}
}
State Management Patterns
Loading/Error/Success (LES) Wrapper
sealed class UiState<out T> {
data object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String, val cause: Throwable? = null) : UiState<Nothing>()
}
Optimistic UI Updates
// Zustand — optimistic update with rollback
const useOrderStore = create<OrderState>((set, get) => ({
deleteOrder: async (id: string) => {
const previousOrders = get().orders;
// Optimistic remove
set((state) => ({
orders: state.orders.filter((o) => o.id !== id),
}));
try {
await api.deleteOrder(id); // Confirm on server
} catch {
set({ orders: previousOrders }); // Rollback on failure
throw new Error('Failed to delete order');
}
},
}));
Error Boundary Pattern
enum AsyncResult<T> {
case idle
case loading
case success(T)
case failure(Error)
var data: T? {
if case .success(let value) = self { return value }
return nil
}
var error: Error? {
if case .failure(let error) = self { return error }
return nil
}
var isLoading: Bool {
if case .loading = self { return true }
return false
}
}
Dependency Injection Setup
Android — Hilt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.build()
@Provides @Singleton
fun provideOrderApi(client: OkHttpClient): OrderApi =
Retrofit.Builder().client(client).build().create(OrderApi::class.java)
}
@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
@Provides @Singleton
fun provideOrderRepository(api: OrderApi, dao: OrderDao): OrderRepository =
OrderRepositoryImpl(api, dao)
}
iOS — Swinject
import Swinject
let container = Container()
container.register(OrderApi.self) { _ in OrderApi() }.inObjectScope(.container)
container.register(OrderDao.self) { _ in OrderDao() }.inObjectScope(.container)
container.register(OrderRepository.self) { r in
OrderRepositoryImpl(api: r.resolve(OrderApi.self)!, dao: r.resolve(OrderDao.self)!)
}.inObjectScope(.container)
container.register(GetOrdersUseCase.self) { r in
GetOrdersUseCase(repository: r.resolve(OrderRepository.self)!)
}
container.register(OrderViewModel.self) { r in
OrderViewModel(getOrdersUseCase: r.resolve(GetOrdersUseCase.self)!)
}
Flutter — GetIt
final sl = GetIt.instance;
void setupDI() {
sl.registerLazySingleton<OrderApi>(() => OrderApi());
sl.registerLazySingleton<OrderDao>(() => OrderDao());
sl.registerLazySingleton<OrderRepository>(() =>
OrderRepositoryImpl(api: sl(), dao: sl()));
sl.registerFactory(() => OrderViewModel(repo: sl()));
}
Anti-Patterns
- Massive ViewModel: Thousands of lines with all logic. Decompose into UseCases, separate concerns
- Leaking framework into domain: Room annotations, Retrofit types in domain layer. Domain must be pure
- View holding ViewModel reference: Prevents garbage collection. ViewModel survives config changes
- Navigator called from View: Ties screen to navigation. Use Coordinator callback or navigation event
- One architecture for every screen: Different screens need different complexity. Simple screens can use simpler patterns
- Data layer knowing about presentation: Returns UI-specific models. Data returns domain models only
- No UseCase when logic is simple: Adds boilerplate with no benefit. UseCases only when logic is shared or complex
- State exposed as mutable object: Multiple sources of mutation cause race conditions. Expose immutable state only
- DI creates circular dependencies: Two classes depending on each other via DI. Extract shared dependency
- Singletons everywhere: Global state makes testing impossible. Scope DI to appropriate lifecycle
- Repository leaking cache details: Callers should not know about cache strategy. Repository abstracts it
Testing Patterns
ViewModel Test (Android)
class OrderViewModelTest {
private val useCase = mockk<GetOrdersUseCase>()
private val vm = OrderViewModel(useCase)
@Test
fun `load orders emits success state`() = runTest {
val orders = listOf(Order("1", "Test"))
coEvery { useCase() } returns Result.success(orders)
vm.onAction(OrderAction.Refresh)
val state = vm.state.first { !it.isLoading }
assertEquals(orders, state.orders)
}
}
ViewModel Test (iOS)
@Testable import MyApp
final class OrderViewModelTests: XCTestCase {
func testLoadOrders() async {
let mockUseCase = MockGetOrdersUseCase()
mockUseCase.stubResult = [Order(id: "1", title: "Test")]
let vm = await OrderViewModel(getOrdersUseCase: mockUseCase)
await vm.loadOrders()
let orders = await vm.orders
XCTAssertEqual(orders.count, 1)
}
}
Performance Considerations
- ViewModel state should be as flat as possible (minimize data class copies)
- Use
distinctUntilChanged/skipRepeatsto avoid unnecessary recomposition - Debounce search inputs by 300ms before dispatching intents
- Unsubscribe from observers in
onCleared/deinit/disposeto prevent leaks - Prefer
StateFlowoverLiveDataon Android for better lifecycle handling - Use
@Stableannotations on Compose /equatableon Flutter to reduce recomposition - Lazy-load feature modules to reduce cold start time
- Keep UseCases stateless — inject state through parameters
- Profile recomposition counts in debug builds to detect unnecessary redraws
References
references/architecture-patterns.md— Mobile Architecture Patternsreferences/clean-arch.md— Clean Architecture for Mobilereferences/coordinator.md— Coordinator / Navigatorreferences/design-patterns.md— Mobile Design Patternsreferences/mobile-architecture-patterns.md— Mobile Architecture Patternsreferences/mvvm-mvi.md— MVVM vs MVI
Handoff
After architecture selection, hand off to:
mobile/universal/testing— Architecture testability, unit testing patternsmobile/universal/networking— Repository + API layer integrationmobile/universal/offline-first— Offline repository patternsmobile/universal/performance— Architecture reactivity performancemobile/android— Jetpack Compose, Hilt, Navigationmobile/ios— SwiftUI, Combine, Coordinatormobile/flutter— Riverpod, GoRouter, BLoCmobile/react-native— Zustand, React Navigation