Skip to content

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).

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.

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