Mobile No. 336

iOS Architecture skill for Claude Code

SwiftUI apps with real boundaries and injected services. Claude gives SwiftUI apps a composition root that wires live dependencies once, feature models that take repository protocols in their initializers, a router per NavigationStack, and persistence hidden behind those protocols. Features move into local Swift package targets that build and preview alone, and Swift Testing suites run against shared fakes under Swift 6 strict concurrency.

  • nameios-architecture
  • size230 lines · ≈2,450 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Structures iOS apps built with SwiftUI and the Observation framework — a composition root, dependency injection through initializers and the SwiftUI environment, per-feature routers over NavigationStack paths, repositories over SwiftData, Core Data, or GRDB, Swift Package Manager feature modules, Swift 6 concurrency defaults, and Swift Testing with fakes; with notes on when TCA or MVVM fits. Use when starting an iOS app, a SwiftUI project has grown into singletons and massive views, choosing SwiftData vs. Core Data, splitting into local packages, or making features testable and previewable.

Try asking

  • Our SwiftUI app is all singletons and huge views — propose and start an architecture refactor
  • Should we use SwiftData, Core Data or GRDB for this app? We need widgets and background sync
  • Split the app into local Swift packages per feature so previews build faster

Use it when

  • Starting a new iOS app and deciding its structure
  • Views call URLSession, ModelContext, or singletons directly
  • Choosing persistence or adding migrations
  • Modularizing with Swift Package Manager or adopting TCA

Skip it when

  • Fixing one screen's state wrappers, layout, or previews — use swiftui-views
  • Resolving Sendable and actor-isolation compiler errors — use swift-concurrency

How to check the result

Each feature package builds and previews alone with fakes, tests pass in Swift 6 mode, and the diff adds no singletons or direct I/O inside views.

The common mistake it prevents

Defaulting environment keys to live services, so a forgotten injection silently sends previews and tests to the production backend.

~/.claude/skills/ios-architecture/SKILL.md

--- name: ios-architecture description: Structures iOS apps built with SwiftUI and the Observation framework — a composition root, dependency injection through initializers and… license: MIT ---

Done well means: one composition root builds the dependency graph, features receive dependencies through initializers or the environment (no static let shared reached from views), navigation state lives in a router per flow, persistence hides behind a protocol the UI never sees past, features live in Swift packages that build and preview on their own, and logic is tested with fakes under Swift 6 strict concurrency.

Individual screen patterns (property wrappers, .task, layout, previews) live in swiftui-views; actor and Sendable errors live in swift-concurrency.

Before changing anything

  • Note the deployment target. @Observable, SwiftData, and Swift Testing need iOS 17 / Xcode 16+; below that, use ObservableObject and Core Data or GRDB.
  • Check Swift language mode and concurrency settings per target and package (Swift 6 mode, default actor isolation, approachable concurrency flags).
  • Map singletons (static let shared), @EnvironmentObject usage, and where networking and persistence are called from. Views that call URLSession or ModelContext directly are the first extraction targets.
  • Build and run tests to get a baseline; record build time for later comparison.

Choose the pattern

SituationUse
Most apps, small to mid teamsSwiftUI views + @Observable models per feature + repositories (lightweight MVVM)
Complex state, many effects, strong testing culture, team knows itThe Composable Architecture (TCA)
Large UIKit codebaseKeep UIKit coordinators; host new SwiftUI screens with UIHostingController
Very simple appViews + @Query + @Observable app model; skip repositories until a second data source appears

Do not wrap every view in a ViewModel by reflex; a model earns its place when it holds async work, business rules, or state shared by several views.

Target layout

App/                      # thin app target: @main, composition root, app-wide routing
Packages/
  Core/                   # Models, protocols (OrdersRepository), utilities; no UI, no I/O
  Networking/             # APIClient, DTOs, live repository impls
  Persistence/            # SwiftData/Core Data/GRDB stack, live repository impls
  DesignSystem/           # colors, typography, components
  Features/
    OrdersFeature/        # views, models, router; depends on Core + DesignSystem only
    SettingsFeature/
  TestSupport/            # fakes and fixtures shared by tests and previews
  • Feature packages depend on Core protocols, never on Networking or Persistence; the app target injects live implementations.
  • One local package with many targets (Package.swift listing each library) is simpler than many packages; split packages only for separate ownership or versioning.
  • Enable strict settings in each Package.swift:
// swift-tools-version: 6.0
let swiftSettings: [SwiftSetting] = [
    .enableUpcomingFeature("ExistentialAny"),
]
let package = Package(
    name: "Packages",
    platforms: [.iOS(.v17)],
    products: [.library(name: "OrdersFeature", targets: ["OrdersFeature"])],
    targets: [
        .target(name: "Core", swiftSettings: swiftSettings),
        .target(name: "OrdersFeature", dependencies: ["Core", "DesignSystem"], swiftSettings: swiftSettings),
        .testTarget(name: "OrdersFeatureTests", dependencies: ["OrdersFeature", "TestSupport"]),
    ]
)

Tools version 6.0 defaults targets to the Swift 6 language mode. For UI feature targets on Swift 6.2 (tools version 6.2), consider main-actor default isolation (.defaultIsolation(MainActor.self)) to cut annotation noise; keep Core and Networking nonisolated.

Workflow

- [ ] 1. Pick one feature; define its models and repository protocol in Core
- [ ] 2. Move I/O into live repository implementations (Networking/Persistence)
- [ ] 3. Feature model (@Observable, @MainActor) takes the protocol in its initializer
- [ ] 4. Router for the feature's NavigationStack path; deep-link entry point
- [ ] 5. Composition root wires live deps; previews and tests wire fakes
- [ ] 6. Extract the feature into a package target; it must build and preview alone
- [ ] 7. Tests with Swift Testing against fakes; repeat for the next feature

Dependency injection

// Core
public protocol OrdersRepository: Sendable {
    func orders(matching query: String) async throws -> [Order]
    func observeChanges() -> AsyncStream<Void>
}

// App target: composition root
@MainActor
final class AppDependencies {
    let orders: any OrdersRepository
    let session: SessionStore
    init(environment: AppEnvironment) {
        let api = APIClient(baseURL: environment.baseURL)
        let store = try! PersistenceStack(inMemory: environment.isUITest)  // crash early on misconfig
        orders = LiveOrdersRepository(api: api, store: store)
        session = SessionStore(api: api)
    }
}

@main
struct AcmeApp: App {
    @State private var deps = AppDependencies(environment: .current)
    var body: some Scene {
        WindowGroup { RootView(deps: deps) }
    }
}
MechanismUse for
Initializer parametersDefault: feature models and repositories; explicit and testable
@Environment with an @Entry keyCross-cutting values many views read (theme, feature flags, analytics, a router)
.environment(observableObject)App-wide observable state such as session
DI library (Factory, swift-dependencies)Large graphs where manual wiring becomes noisy
extension EnvironmentValues {
    @Entry var analytics: any Analytics = NoopAnalytics()
}

Never default an environment key to a live network client; a forgotten injection then hits production from a preview or test.

@MainActor @Observable
final class OrdersRouter {
    enum Route: Hashable { case detail(Order.ID), edit(Order.ID) }
    var path: [Route] = []
    var sheet: Sheet?
    enum Sheet: Identifiable { case newOrder; var id: Self { self } }

    func open(_ url: URL) { if let id = OrderLink(url)?.orderID { path = [.detail(id)] } }
    func popToRoot() { path.removeAll() }
}
  • One router per independent stack (each tab owns its own). The app-level router owns the selected tab and forwards deep links to the right feature router.
  • Features expose an entry view and a route type; they do not know about other features. The app target maps cross-feature navigation (orders to profile) through closures.
  • Persist path with Codable routes if state restoration matters.

Persistence

NeedUse
iOS 17+, straightforward models, SwiftUI-driven listsSwiftData (@Model, ModelContainer, @Query)
Heavy migrations, large datasets, iOS < 17, mature toolingCore Data
SQL control, precise queries, sync engines, testabilityGRDB (SQLite)
Small settingsUserDefaults / @AppStorage; secrets go in the Keychain
  • SwiftData: @Query in views is fine for simple local lists. When logic grows, move to a repository that uses a @ModelActor for background writes and returns plain value types or PersistentIdentifiers; @Model objects are not Sendable across actors.
  • Define VersionedSchema and a SchemaMigrationPlan from the first release; adding them after shipping is harder.
  • Share the store with widgets via an App Group container URL in ModelConfiguration.
  • Keep DTOs (Codable network types) separate from persistence models and domain models once they diverge; map at the repository boundary.

Testing

import Testing
@testable import OrdersFeature

@MainActor
struct OrdersModelTests {
    @Test func loadsOrders() async throws {
        let repo = FakeOrdersRepository(result: .success([.fixture(id: "1")]))
        let model = OrdersModel(repo: repo)
        await model.load()
        #expect(model.orders.map(\.id) == ["1"])
    }

    @Test(arguments: [URLError(.notConnectedToInternet), URLError(.timedOut)])
    func showsErrorOnFailure(error: URLError) async {
        let model = OrdersModel(repo: FakeOrdersRepository(result: .failure(error)))
        await model.load()
        #expect(model.errorMessage != nil)
    }
}
  • Fakes live in TestSupport and power previews too, so previews never hit the network.
  • Run package tests fast with swift test only for platform-neutral targets; UI feature targets need xcodebuild test -scheme OrdersFeature -destination 'platform=iOS Simulator,name=iPhone 16'.
  • UI tests launch with an argument (-uiTest) that the composition root reads to use in-memory stores and stubbed network.

Gotchas

  • @State var model = Model() in a view that is recreated by its parent with different inputs keeps the old model; pass identity with .id(...) or own the model higher up.
  • @Observable classes held in @Environment must be injected before any view reads them, or the app crashes at runtime with a missing-environment error.
  • Singletons accessed from nonisolated contexts trigger Swift 6 data-race errors; inject instead of making everything @MainActor.
  • Circular package dependencies fail resolution; push shared types down into Core.
  • Giant AppState objects observed by every view cause broad re-renders; split state by feature so Observation tracks narrow properties.

Verification

  1. Every feature package builds alone: xcodebuild build -scheme OrdersFeature.
  2. Previews for each feature render with fakes and no network access.
  3. xcodebuild test passes with Swift 6 language mode and no concurrency warnings.
  4. Search the diff: no new static let shared reached from views, no URLSession or ModelContext in view files outside @Query use.
  5. Deep link and tab switching land on the right screen with the expected back stack.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc