Claude
Skills
Sign in
Back

swift-architecture

Included with Lifetime
$97 forever

Select, implement, or migrate between app architecture patterns for Apple platform apps. Use when choosing between MV (Model-View with @Observable), MVVM, MVI, TCA (The Composable Architecture), Clean Architecture, VIPER, or Coordinator patterns; when evaluating architecture fit for a feature's complexity; when migrating from one pattern to another; or when reviewing whether an app's current architecture is appropriate. Scoped to Apple-platform patterns using Swift 6.3, SwiftUI, and UIKit.

General

What this skill does


# Swift Architecture

Select and implement the right architecture pattern for Apple platform apps built with Swift 6.3 and SwiftUI or UIKit.

## Contents

- [Scope Boundary](#scope-boundary)
- [Architecture Selection](#architecture-selection)
- [MV Pattern (Model-View with `@Observable`)](#mv-pattern)
- [MVVM](#mvvm)
- [MVI (Model-View-Intent)](#mvi)
- [TCA (The Composable Architecture)](#tca)
- [Clean Architecture](#clean-architecture)
- [Coordinator Pattern](#coordinator-pattern)
- [VIPER](#viper)
- [Migration Between Patterns](#migration-between-patterns)
- [Common Mistakes](#common-mistakes)
- [Review Checklist](#review-checklist)
- [References](#references)

## Scope Boundary

This skill owns architecture-level decisions: pattern selection, module
boundaries, dependency direction, migration/escalation strategy, and structural
test strategy. It does not own SwiftUI state mechanics; route `@State`,
`@Bindable`, `@Environment`, edit-sheet/local state, bindings, view composition,
and `@Observable` MV implementation mechanics to `swiftui-patterns`. Use
`swiftui-navigation` for `NavigationStack`, `NavigationSplitView`,
`NavigationPath`, route models, sheets, tabs, and deep-link URL handling;
`swift-concurrency` for `@MainActor`, default MainActor isolation, `Sendable`,
strict-concurrency diagnostics, and data-race diagnostics; and `swift-testing`
for `@Test`, `#expect`, `#require`, fixtures, parameterized tests, mocks, stubs,
and suite organization.

## Architecture Selection

| Pattern | Best For | Complexity | Testability |
|---------|----------|-----------|-------------|
| **MV** | Small-to-medium SwiftUI apps, rapid iteration | Low | Moderate |
| **MVVM** | Medium apps, teams familiar with reactive patterns | Medium | High |
| **MVI** | Complex state machines, predictable state flow | Medium-High | High |
| **TCA** | Large apps needing composable features, strong testing | High | Very High |
| **Clean Architecture** | Enterprise apps, strict separation of concerns | High | Very High |
| **Coordinator** | Apps with complex navigation flows (UIKit or hybrid) | Medium | High |
| **VIPER** | Legacy UIKit modules already using VIPER boundaries | Very High | High |

**Default recommendation for new SwiftUI apps:** Start with MV (Model-View
with `@Observable`). Escalate to MVVM or TCA only when the feature's complexity
demands it.

Boundary-split answers should use one `swift-architecture` bucket for
pattern/module/dependency/migration/test-strategy decisions. Do not add a
separate architecture-owned "SwiftUI state ownership" bucket; property-wrapper,
local binding, navigation, concurrency-diagnostic, fixture, and parameterized
test mechanics are sibling-skill handoffs.

### Decision Framework

1. **Is the feature a simple CRUD screen?** → MV pattern
2. **Does the screen have complex business logic separate from the view?** → MVVM
3. **Do you need deterministic state transitions and side-effect management?** → MVI or TCA
4. **Is the app large with many independent feature modules?** → TCA or Clean Architecture
5. **Is navigation complex with deep linking and conditional flows?** → Add Coordinator pattern

## MV Pattern

The simplest SwiftUI architecture. The view observes `@Observable` models
directly. No intermediate view model layer.

```swift
import Observation
import SwiftUI

@MainActor
@Observable
final class TripStore {
    var trips: [Trip] = []
    var isLoading = false
    var error: Error?

    private let service: TripService

    init(service: TripService) {
        self.service = service
    }

    func loadTrips() async {
        isLoading = true
        defer { isLoading = false }
        do {
            trips = try await service.fetchTrips()
        } catch {
            self.error = error
        }
    }

    func deleteTrip(_ trip: Trip) async throws {
        try await service.delete(trip)
        trips.removeAll { $0.id == trip.id }
    }
}

struct TripsView: View {
    @State private var store = TripStore(service: .live)

    var body: some View {
        List(store.trips) { trip in
            TripRow(trip: trip)
        }
        .task { await store.loadTrips() }
    }
}
```

**When MV is enough:** Single-screen features, prototype/MVP, small teams,
straightforward data flow.

**When to upgrade:** Business logic grows complex, unit testing the view's
behavior becomes difficult, multiple views need to share and transform the
same state differently.

## MVVM

Separates view logic into a `ViewModel` that the view observes. The view model
transforms model data for display and handles user actions.

```swift
@MainActor
@Observable
final class TripListViewModel {
    private(set) var trips: [TripRowItem] = []
    private(set) var isLoading = false
    var searchText = ""

    var filteredTrips: [TripRowItem] {
        guard !searchText.isEmpty else { return trips }
        return trips.filter { $0.name.localizedStandardContains(searchText) }
    }

    private let repository: TripRepository

    init(repository: TripRepository) {
        self.repository = repository
    }

    func loadTrips() async {
        isLoading = true
        defer { isLoading = false }
        let models = (try? await repository.fetchAll()) ?? []
        trips = models.map { TripRowItem(from: $0) }
    }

    func delete(at offsets: IndexSet) async {
        let toDelete = offsets.map { filteredTrips[$0] }
        for item in toDelete {
            try? await repository.delete(id: item.id)
        }
        await loadTrips()
    }
}

struct TripRowItem: Identifiable {
    let id: UUID
    let name: String
    let dateRange: String

    init(from trip: Trip) {
        self.id = trip.id
        self.name = trip.name
        self.dateRange = trip.startDate.formatted(.dateTime.month().day())
            + " – " + trip.endDate.formatted(.dateTime.month().day())
    }
}

struct TripListView: View {
    @State private var viewModel: TripListViewModel

    init(repository: TripRepository) {
        _viewModel = State(initialValue: TripListViewModel(repository: repository))
    }

    var body: some View {
        List {
            ForEach(viewModel.filteredTrips) { item in
                Text(item.name)
            }
            .onDelete { offsets in
                Task { await viewModel.delete(at: offsets) }
            }
        }
        .searchable(text: $viewModel.searchText)
        .task { await viewModel.loadTrips() }
    }
}
```

**Testing a ViewModel:**

```swift
@Test func filteredTripsMatchesSearch() async {
    let repo = MockTripRepository(trips: [
        Trip(name: "Paris"), Trip(name: "Tokyo"), Trip(name: "Paris TX")
    ])
    let vm = TripListViewModel(repository: repo)
    await vm.loadTrips()
    vm.searchText = "Paris"
    #expect(vm.filteredTrips.count == 2)
}
```

## MVI

Unidirectional data flow: views dispatch **intents**, a **reducer** produces
new **state**, and **side effects** are handled explicitly.

```swift
@MainActor
@Observable
final class TripListStore {
    private(set) var state = State()

    struct State {
        var trips: [Trip] = []
        var isLoading = false
        var error: String?
    }

    enum Intent {
        case loadTrips
        case deleteTrip(Trip)
        case clearError
    }

    private let service: TripService

    init(service: TripService) {
        self.service = service
    }

    func send(_ intent: Intent) {
        Task { await handle(intent) }
    }

    private func handle(_ intent: Intent) async {
        switch intent {
        case .loadTrips:
            state.isLoading = true
            do {
                state.trips = try await service.fetchTrips()
            } catch {
                state.error = error.localizedDescription
            }
            state.isLoading = false

        case .deleteTrip(let trip):
            try? await service.delete(trip)
            state.trips.removeAll { $0.id == trip.id }

        case .clearError:
            state.error = nil
        }
    }

Related in General