Swift / SwiftUI — Architecture¶
OWNER: martijn, valentin ALSO_USED_BY: alexander (design integration) LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: Swift 6.1 / SwiftUI (iOS 26 SDK)
Overview¶
GE uses MVVM with @Observable (Swift 5.9+) as the default architecture for all iOS apps.
This page covers navigation, data flow, dependency injection, and when to deviate.
Agents generating SwiftUI views or view models MUST follow these patterns.
MVVM with @Observable¶
The Three Layers¶
| Layer | Responsibility | SwiftUI Mapping |
|---|---|---|
| Model | Business logic, domain objects, data access | Plain Swift structs/classes, SwiftData @Model |
| View | UI rendering, user interaction capture | SwiftUI View structs |
| ViewModel | Presentation logic, state management, async coordination | @Observable classes |
ViewModel Declaration¶
CHECK: Agent is creating a ViewModel.
IF: ViewModel uses ObservableObject with @Published.
THEN: Migrate to @Observable. The ObservableObject protocol is legacy.
// CORRECT — @Observable (Swift 5.9+)
@Observable
final class OrderListViewModel {
var orders: [Order] = []
var isLoading = false
var errorMessage: String?
private let orderService: OrderServiceProtocol
init(orderService: OrderServiceProtocol) {
self.orderService = orderService
}
func loadOrders() async {
isLoading = true
defer { isLoading = false }
do {
orders = try await orderService.fetchOrders()
} catch {
errorMessage = error.localizedDescription
}
}
}
ANTI_PATTERN: Using @ObservedObject or @StateObject with @Observable classes.
FIX: Use @State for owned @Observable objects. Use plain properties for injected ones.
// CORRECT — Owning an @Observable ViewModel
struct OrderListView: View {
@State private var viewModel = OrderListViewModel(
orderService: OrderService()
)
var body: some View {
List(viewModel.orders) { order in
OrderRow(order: order)
}
.task { await viewModel.loadOrders() }
}
}
When a ViewModel is NOT Needed¶
CHECK: Agent is creating a view.
IF: The view only displays static data or uses @State for simple local state.
THEN: Do NOT create a ViewModel. Let @State and @Binding handle it.
Views that are pure display (e.g., OrderRow, StatusBadge) do not need ViewModels.
Only root-level feature views and views with async logic need ViewModels.
Nesting ViewModels in Extensions¶
GE convention: place the ViewModel as a nested type inside the View's extension. This provides automatic type disambiguation without long class names.
extension OrderListView {
@Observable
final class ViewModel {
var orders: [Order] = []
// ...
}
}
struct OrderListView: View {
@State private var viewModel = ViewModel(/* ... */)
// ...
}
CHECK: Agent is naming a ViewModel.
IF: ViewModel is specific to a single view.
THEN: Use the nested extension pattern above.
IF: ViewModel is shared across multiple views.
THEN: Use a standalone class with a descriptive name (e.g., CartViewModel).
Navigation Patterns¶
NavigationStack (Primary)¶
GE uses NavigationStack with type-safe NavigationPath for all navigation.
@Observable
final class Router {
var path = NavigationPath()
func navigate(to destination: AppDestination) {
path.append(destination)
}
func popToRoot() {
path.removeLast(path.count)
}
}
enum AppDestination: Hashable {
case orderDetail(Order)
case settings
case profile(User)
}
ANTI_PATTERN: Using NavigationView.
FIX: Replace with NavigationStack. NavigationView is deprecated since iOS 16.
ANTI_PATTERN: Using @State booleans for navigation (isShowingDetail).
FIX: Use NavigationPath or navigationDestination(for:) with typed values.
NavigationStack Setup¶
struct ContentView: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: AppDestination.self) { dest in
switch dest {
case .orderDetail(let order):
OrderDetailView(order: order)
case .settings:
SettingsView()
case .profile(let user):
ProfileView(user: user)
}
}
}
.environment(router)
}
}
TabView Navigation¶
Each tab owns its own NavigationStack.
The Router is shared via @Environment for cross-tab deep linking.
struct MainTabView: View {
var body: some View {
TabView {
Tab("Orders", systemImage: "list.bullet") {
NavigationStack {
OrderListView()
}
}
Tab("Profile", systemImage: "person") {
NavigationStack {
ProfileView()
}
}
}
}
}
Sheet and Alert Presentation¶
Use @State with optional item pattern for sheets.
struct OrderListView: View {
@State private var selectedOrder: Order?
var body: some View {
List(orders) { order in
Button(order.title) {
selectedOrder = order
}
}
.sheet(item: $selectedOrder) { order in
OrderDetailView(order: order)
}
}
}
Data Flow¶
Property Wrapper Decision Tree¶
Is the data owned by THIS view?
YES → Is it a simple value type (String, Int, Bool, struct)?
YES → @State
NO → Is it an @Observable class?
YES → @State (for owned instances)
NO → @State (for reference types you create here)
NO → Is it passed from a parent view?
YES → Does the child need to WRITE to it?
YES → @Binding
NO → Plain property (let)
NO → Is it provided via the environment?
YES → @Environment
NO → Inject via initializer
@Environment for Shared Services¶
// Define the environment key
extension EnvironmentValues {
@Entry var orderService: OrderServiceProtocol = OrderService()
}
// Inject at the root
ContentView()
.environment(\.orderService, OrderService(client: apiClient))
// Consume in any descendant
struct OrderListView: View {
@Environment(\.orderService) private var orderService
}
ANTI_PATTERN: Passing services through 5+ levels of initializers (prop drilling).
FIX: Use @Environment for services that are needed deep in the view hierarchy.
@Observable with @Environment (Direct Injection)¶
For @Observable classes, you can inject them directly into the environment.
// At the root
ContentView()
.environment(router)
// In any descendant
struct DeepView: View {
@Environment(Router.self) private var router
}
Dependency Injection¶
Protocol-Based DI (GE Standard)¶
Every service accessed by a ViewModel MUST be behind a protocol. This enables testing with mock implementations.
protocol OrderServiceProtocol: Sendable {
func fetchOrders() async throws -> [Order]
func createOrder(_ order: Order) async throws -> Order
}
final class OrderService: OrderServiceProtocol {
private let client: APIClient
init(client: APIClient) {
self.client = client
}
func fetchOrders() async throws -> [Order] {
try await client.get("/orders")
}
func createOrder(_ order: Order) async throws -> Order {
try await client.post("/orders", body: order)
}
}
Testing with Mocks¶
final class MockOrderService: OrderServiceProtocol {
var ordersToReturn: [Order] = []
var shouldThrow = false
func fetchOrders() async throws -> [Order] {
if shouldThrow { throw TestError.simulated }
return ordersToReturn
}
func createOrder(_ order: Order) async throws -> Order {
if shouldThrow { throw TestError.simulated }
return order
}
}
@Test
func loadOrders_success() async {
let mock = MockOrderService()
mock.ordersToReturn = [Order.sample]
let vm = OrderListViewModel(orderService: mock)
await vm.loadOrders()
#expect(vm.orders.count == 1)
#expect(vm.isLoading == false)
}
CHECK: Agent is writing a ViewModel that calls an API. IF: The service is injected as a concrete type. THEN: Extract a protocol and inject the protocol instead.
No DI Containers¶
GE does NOT use third-party DI containers (Swinject, Factory, etc.).
Dependencies are injected through initializers and @Environment.
This keeps the dependency graph explicit and compile-time checked.
ANTI_PATTERN: Using a service locator or global singleton for dependencies.
FIX: Inject via initializer or @Environment. Singletons hide dependencies and break testability.
SwiftData Integration¶
CHECK: Agent is using SwiftData alongside MVVM.
IF: ViewModel directly imports SwiftData or holds a ModelContext.
THEN: Restructure. The ViewModel should call a repository/service that wraps SwiftData.
SwiftData's @Query works best directly in Views, not ViewModels.
For complex data operations, use a repository pattern.
protocol OrderRepositoryProtocol {
func fetchLocal() async throws -> [Order]
func save(_ order: Order) async throws
func sync() async throws
}
final class SwiftDataOrderRepository: OrderRepositoryProtocol {
private let context: ModelContext
init(context: ModelContext) {
self.context = context
}
func fetchLocal() async throws -> [Order] {
let descriptor = FetchDescriptor<Order>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
return try context.fetch(descriptor)
}
// ...
}
READ_ALSO: wiki/docs/stack/swift-swiftui/persistence.md
Error Handling Architecture¶
Result-Based Errors for ViewModels¶
@Observable
final class OrderListViewModel {
var state: ViewState<[Order]> = .idle
func loadOrders() async {
state = .loading
do {
let orders = try await orderService.fetchOrders()
state = .loaded(orders)
} catch {
state = .error(error.localizedDescription)
}
}
}
enum ViewState<T> {
case idle
case loading
case loaded(T)
case error(String)
}
CHECK: Agent is handling errors in a ViewModel.
IF: Error is silently caught with an empty catch {} block.
THEN: Always surface the error to the user via state or log it.
Cross-References¶
READ_ALSO: wiki/docs/stack/swift-swiftui/index.md READ_ALSO: wiki/docs/stack/swift-swiftui/networking.md READ_ALSO: wiki/docs/stack/swift-swiftui/persistence.md READ_ALSO: wiki/docs/stack/swift-swiftui/pitfalls.md