# Architecture Patterns

The reasoning behind the Model-View default, the cases where a view model still
earns its place, and a reference wiring for the app shell.

## Contents

1. [MV Principles](#1-mv-principles)
2. [Why Not MVVM](#2-why-not-mvvm)
3. [MV in Practice](#3-mv-in-practice)
4. [Working With an Existing View Model](#4-working-with-an-existing-view-model)
5. [When a New View Model Is Justified](#5-when-a-new-view-model-is-justified)
6. [Environment or Initializer Injection](#6-environment-or-initializer-injection)
7. [Testing Strategy](#7-testing-strategy)
8. [App Shell and Dependency Graph](#8-app-shell-and-dependency-graph)
9. [Lightweight Clients](#9-lightweight-clients)
10. [Further Reading](#10-further-reading)

## 1. MV Principles

- Views run the UI flow with `@State`, `@Environment`, `@Query`, `.task` and
  `.onChange`.
- Services and shared models live in the environment. Each one can be tested
  on its own and keeps its complexity behind a small surface.
- When a view gets big, extract subviews first. Add a view model only when the
  existing code already depends on one.

## 2. Why Not MVVM

- A SwiftUI view is a struct that is cheap to build and rebuilt often. A view
  model that mirrors it pushes against that design.
- Apple's data-flow sessions barely mention view models: *Data Flow Through
  SwiftUI* (WWDC19), *Data Essentials in SwiftUI* (WWDC20), *Discover
  Observation in SwiftUI* (WWDC23).
- Every view model adds cost: another object to keep in step with the view,
  another hop to read through, and hand-written fetching that repeats what
  SwiftUI and SwiftData already do.

## 3. MV in Practice

### A view backed by an environment service

```swift
struct ArticlesView: View {
    @Environment(NewsService.self) private var news
    @Environment(Branding.self) private var branding

    enum Phase {
        case fetching
        case failed(String)
        case ready([Article])
    }

    @State private var phase: Phase = .fetching

    var body: some View {
        NavigationStack {
            List {
                switch phase {
                case .fetching:
                    ProgressView("Fetching articles")
                        .frame(maxWidth: .infinity)
                        .listRowSeparator(.hidden)
                case .failed(let reason):
                    ContentUnavailableView("No articles",
                                           systemImage: "wifi.exclamationmark",
                                           description: Text(reason))
                        .listRowSeparator(.hidden)
                case .ready(let articles):
                    ForEach(articles) { ArticleRow(article: $0) }
                }
            }
            .listStyle(.plain)
            .refreshable { await fetch() }
            .task { await fetch() }
        }
    }

    private func fetch() async {
        do { phase = .ready(try await news.latest()) }
        catch { phase = .failed(error.localizedDescription) }
    }
}
```

### Modifiers as small reducers

Treat `.task(id:)` and `.onChange` as tiny reducers: an input changes, a bit of
state follows.

```swift
.task(id: filterText) {
    guard !filterText.isEmpty else { return }
    await runFilter(filterText)
}
.onChange(of: isFiltering, initial: false) { _, active in
    if !active {
        Task { await loadDefaultArticles() }
    }
}
```

### Dependencies installed once at the app level

```swift
@main
struct NewsReaderApp: App {
    @State private var news = NewsService()
    @State private var session = SessionManager()
    @State private var router = TabRouter(startTab: .today)

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(news)
                .environment(session)
                .environment(router)
        }
    }
}
```

Each dependency is created one time and every view below can reach it.

### SwiftData directly in views

SwiftData is designed to be queried from views. Wrapping it in a view model
means fetching and refreshing by hand, plus boilerplate.

```swift
struct ShelfView: View {
    @Query(sort: \Novel.title) private var novels: [Novel]
    @Environment(\.modelContext) private var context

    var body: some View {
        List {
            ForEach(novels) { novel in
                NovelRow(novel: novel)
                    .swipeActions {
                        Button("Delete", role: .destructive) {
                            context.delete(novel)
                        }
                    }
            }
        }
    }
}
```

## 4. Working With an Existing View Model

When the project already has view models, keep them tidy:

- Make the view model non-optional where you can.
- Take dependencies in the view's `init` and hand them to the view model
  there.
- Hold the view model in `@State` in the view that owns it.
- Skip lazy `bootstrapIfNeeded`-style setup.

```swift
@MainActor @Observable
final class AccountEditorModel {
    var displayName = ""
    var isSubmitting = false
    private let api: AccountAPI

    init(api: AccountAPI) { self.api = api }

    func submit() async throws {
        isSubmitting = true
        defer { isSubmitting = false }
        try await api.rename(to: displayName)
    }
}

struct AccountEditorScreen: View {
    @State private var model: AccountEditorModel

    init(api: AccountAPI) {
        _model = State(initialValue: AccountEditorModel(api: api))
    }

    var body: some View { AccountEditorForm(model: model) }
}

struct AccountEditorForm: View {
    @Bindable var model: AccountEditorModel

    var body: some View {
        Form {
            TextField("Display name", text: $model.displayName)
            Button("Submit") {
                Task { try? await model.submit() }
            }
            .disabled(model.isSubmitting)
        }
    }
}
```

## 5. When a New View Model Is Justified

Add one only when the view would otherwise be hard to read or hard to test.
Cases that qualify:

- Multi-step flows such as onboarding, checkout or a wizard, where every step
  edits the same draft.
- Real business logic: validation chains, state derived from several sources,
  transformation pipelines that do not fit a lightweight client.
- Coordinating several publishers or `AsyncSequence` streams whose transitions
  depend on each other.
- A code base that already tests against a view model interface, where moving
  to MV would cost more than it returns.

The test is readability and testability. Familiarity with MVVM from other
platforms is not a reason.

## 6. Environment or Initializer Injection

Use `@Environment` for dependencies many views need at different depths:

- app-wide services: authentication, the network client, theme, router
- the SwiftData `ModelContext`
- a feature store installed at a navigation root

Use initializer parameters for data that belongs to one instance:

- the selected item, a filter mode, a configuration value
- data one child needs from its parent
- values fixed at the call site

Init parameters make requirements visible and keep previews simple. If three or
more views in between would only forward a parameter, move it to the
environment.

## 7. Testing Strategy

- Unit test services and business rules.
- Test models and data transformations.
- Use SwiftUI previews to catch visual regressions.
- Cover end-to-end flows with UI automation.
- Keep views simple enough that they need no unit tests of their own.

## 8. App Shell and Dependency Graph

### Goal

Wire the shell (tab view, navigation stacks, sheets) and install global
dependencies (environment objects, services, streaming clients, the SwiftData
`ModelContainer`) in one place:

1. The root view builds the tabs, one router per tab, and the sheets.
2. One view modifier installs global dependencies and lifecycle work: auth
   state, streaming watchers, push tokens, data containers.
3. Feature views read only what they need from the environment. Feature state
   stays inside the feature.

### Root shell

```swift
@MainActor
struct RootView: View {
    @State private var currentTab: MainTab = .today
    @State private var routers = TabRouter()

    var body: some View {
        TabView(selection: $currentTab) {
            ForEach(MainTab.allCases) { tab in
                let router = routers.router(for: tab)
                Tab(value: tab) {
                    NavigationStack(path: routers.pathBinding(for: tab)) {
                        tab.rootView()
                    }
                    .presentsSheets(Binding(
                        get: { router.activeSheet },
                        set: { router.activeSheet = $0 }
                    ))
                    .environment(router)
                } label: {
                    tab.label
                }
            }
        }
        .tabBarMinimizeBehavior(.onScrollDown)
        .installAppServices()
    }
}
```

`Tab(value:)` needs iOS 18; `.tabBarMinimizeBehavior(_:)` needs iOS 26.

```swift
@MainActor
enum MainTab: Identifiable, Hashable, CaseIterable {
    case today, alerts, preferences

    nonisolated var id: String { String(describing: self) }

    @ViewBuilder
    func rootView() -> some View {
        switch self {
        case .today: TodayView()
        case .alerts: AlertsView()
        case .preferences: PreferencesView()
        }
    }

    @ViewBuilder
    var label: some View {
        switch self {
        case .today: Label("Today", systemImage: "house")
        case .alerts: Label("Alerts", systemImage: "bell")
        case .preferences: Label("Preferences", systemImage: "gear")
        }
    }
}

@MainActor @Observable
final class NavigationRouter {
    var path: [Destination] = []
    var activeSheet: SheetRoute?
}

enum Destination: Hashable {
    case article(id: String)
}
```

`TabRouter` is a small `@Observable` holder that keeps one `NavigationRouter`
per tab and vends a `Binding` to each router's `path`.

`MainTab` is `@MainActor` because it builds views, so its `id` is marked
`nonisolated`; otherwise the `Identifiable` conformance would cross into
main-actor code and Swift 6 rejects it.

### Dependency graph modifier

Install every environment object and lifecycle hook through one modifier. Call
sites stay consistent and none of them can forget a dependency.

```swift
extension View {
    func installAppServices(
        api: APIClient = .shared,
        session: SessionManager = .shared,
        branding: Branding = .shared,
        banners: BannerCenter = .shared
    ) -> some View {
        environment(api)
            .environment(session)
            .environment(branding)
            .environment(banners)
            .task(id: session.activeUser?.id) {
                await api.prepare(for: session.activeUser)
            }
    }
}
```

The `.task(id:)` hook runs again when the signed-in account changes, so
services and watchers are re-seeded for the new user. Keep this modifier to
global wiring; feature state belongs to features. Rename the types to match the
project.

### ModelContainer

Install the `ModelContainer` at the root so every feature shares one store:

```swift
extension View {
    func installDataStore() -> some View {
        modelContainer(for: [Note.self, Folder.self, Attachment.self])
    }
}
```

A single container avoids a separate store per sheet or tab and keeps data
consistent.

### Enum-driven sheets

Keep every sheet in one small `Identifiable` enum plus a helper modifier:

```swift
enum SheetRoute: Identifiable {
    case newPost
    case accountSettings

    var id: Self { self }
}

extension View {
    func presentsSheets(_ route: Binding<SheetRoute?>) -> some View {
        sheet(item: route) { route in
            switch route {
            case .newPost: NewPostView().installAppServices()
            case .accountSettings: AccountSettingsView().installAppServices()
            }
        }
    }
}
```

A new sheet costs one case and one branch. Presentation stays in one place and
is easy to test. Sheets start a new presentation context, so each one applies
the dependency modifier again.

### App entry point

```swift
@main
struct FieldNotesApp: App {
    @State private var api = APIClient.shared
    @State private var session = SessionManager.shared

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(api)
                .environment(session)
                .installDataStore()
        }
    }
}
```

### Deep links

- For state restoration, save the navigation path. `NavigationPath.codable`
  gives a `Codable` representation when every element is `Codable`.
- Handle incoming URLs with `.onOpenURL`:

```swift
.onOpenURL { url in
    guard let destination = Destination(url: url) else { return }
    router.path.append(destination)
}
```

Complete URL routing is covered in `swiftui-navigation`.

### When this structure fits

- Several packages or modules share the same environment objects and services.
- The app has to react to account or client changes and rewire streaming or
  push safely.
- You want tabs, navigation stacks and sheets wired the same way everywhere
  without repeating environment setup.

### Caveats

- Keep the dependency modifier slim: no feature state, no heavy logic.
- Work in `.task(id:)` must be short or cancel cleanly. Long-running work
  belongs in services.
- When a client can exist without a signed-in user, gate streaming and watch
  calls so the app does not fall into a reconnect loop.

## 9. Lightweight Clients

A client can be a plain struct of async closures. There is no view model and
no DI framework, the view never sees networking, and previews or tests swap in
a stub. Business logic sits in a store or feature layer.

```swift
struct CatalogClient {
    var products: (_ page: Int) async throws -> [Product]
    var lookup: (_ term: String, _ page: Int) async throws -> [Product]

    static func live(host: URL) -> CatalogClient {
        let session = URLSession(configuration: .catalog)
        return CatalogClient(
            products: { page in
                let url = host.appending(path: "products")
                    .appending(queryItems: [URLQueryItem(name: "page", value: String(page))])
                let (data, _) = try await session.data(from: url)
                return try JSONDecoder().decode([Product].self, from: data)
            },
            lookup: { term, page in
                let url = host.appending(path: "search")
                    .appending(queryItems: [
                        URLQueryItem(name: "q", value: term),
                        URLQueryItem(name: "page", value: String(page))
                    ])
                let (data, _) = try await session.data(from: url)
                return try JSONDecoder().decode([Product].self, from: data)
            }
        )
    }
}

extension URLSessionConfiguration {
    static var catalog: URLSessionConfiguration {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 30
        config.timeoutIntervalForResource = 300
        config.waitsForConnectivity = true
        config.urlCache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 50_000_000)
        return config
    }
}
```

`URLSession.shared` is fine for a prototype. Production code builds its own
session as above: 30 second request timeout, 300 second resource timeout,
`waitsForConnectivity` on, and a `URLCache`.

```swift
@MainActor @Observable
final class CatalogStore {
    enum Status { case idle, loading, loaded, failed(String) }

    var status: Status = .idle
    var products: [Product] = []
    private let client: CatalogClient

    init(client: CatalogClient) { self.client = client }

    func load(page: Int = 1) async {
        status = .loading
        do {
            products = try await client.products(page)
            status = .loaded
        } catch {
            status = .failed(error.localizedDescription)
        }
    }
}

struct CatalogView: View {
    @Environment(CatalogStore.self) private var store

    var body: some View {
        List(store.products) { ProductRow(product: $0) }
            .task { await store.load() }
    }
}

@main
struct ShopApp: App {
    @State private var store = CatalogStore(client: .live(host: AppConfig.apiHost))

    var body: some Scene {
        WindowGroup {
            CatalogView().environment(store)
        }
    }
}
```

Guidelines:

- URL building and decoding live in the client. State changes live in the
  store.
- The store receives the client in `init` and keeps it `private`.
- No global singletons for stores; install them with `.environment`.
- Add `static func mock(...)` factories for stubbed variants.

Pitfalls:

- UI state kept in the client. It belongs in the store.
- Client closures that capture `self` or view state.

## 10. Further Reading

- "SwiftUI in 2025: Forget MVVM" by Thomas Ricouard, the article this MV
  guidance follows.
- The WWDC data-flow sessions listed in [Why Not MVVM](#2-why-not-mvvm).
