Claude Code with Swift: SwiftUI, SPM, Xcode Workflow
Why Swift projects need a CLAUDE.md
Swift is one of the more difficult ecosystems to bring Claude Code into without configuration. The language has gone through three distinct concurrency models in less than a decade, the UI layer is split between UIKit, AppKit, and SwiftUI with different rules per platform, the project format is a fragile binary-adjacent file that Apple has never officially documented, and the same code has to compile cleanly to iOS, iPadOS, macOS, watchOS, tvOS, and now visionOS. Generated code that looks correct in the editor can still ship a project file that refuses to open in Xcode.
Claude Code understands Swift at a deep level. It knows the language semantics, structured concurrency, the SwiftUI view system, property wrappers, the Combine framework, the major Apple frameworks (Foundation, SwiftData, CoreData, MapKit, AVFoundation), Swift Package Manager, and XCTest. What it does not know is which choices your project has already made: which UI framework you committed to, which concurrency paradigm is canonical, which SDK minimums you target, and which patterns you want for navigation, state, and dependency injection.
Without a CLAUDE.md that pins those decisions, Claude will mix UIKit and SwiftUI in the same screen, generate Combine pipelines next to async/await code that does the same job, pull in SPM packages without version constraints, and touch the .pbxproj file directly in ways that produce subtle corruption. This guide covers the configuration and patterns that prevent those failures. If you have not set up Claude Code yet, the Claude Code setup guide covers installation and authentication first.
The CLAUDE.md at your project root is the first file Claude Code reads in every session. For a Swift project, it needs to answer six questions: which Swift toolchain and SDK versions are pinned, which UI framework is canonical, how the project file is managed, how concurrency is written, how SPM dependencies are added, and how the project builds and tests for each target. Below is the template the rest of the patterns in this post slot into.
# Swift project rules
## Stack
- Swift: 6.0 (strict concurrency mode on)
- Xcode: 16.2 (pinned via .xcode-version)
- iOS deployment target: 17.0
- macOS deployment target: 14.0
- visionOS deployment target: 2.0
- UI framework: SwiftUI (UIKit only when SwiftUI cannot reach the API)
- Concurrency: Swift structured concurrency (async/await, actors, AsyncSequence)
- Persistence: SwiftData for new models, CoreData only for legacy migration
- Networking: URLSession with async APIs, no third-party HTTP clients
- Testing: XCTest plus Swift Testing for new test files
## Project structure
- App targets in App/{Platform}/ (App/iOS, App/macOS, App/visionOS)
- Shared feature code in Sources/Features/{Feature}/
- Shared UI primitives in Sources/DesignSystem/
- Models in Sources/Models/, repositories in Sources/Repositories/
- Tests mirror Sources/ structure exactly: Tests/Features/{Feature}Tests/
- Project file generated by XcodeGen from project.yml. Never edit .pbxproj directly.
## Project file rules
- The .xcodeproj is a build artifact. Source of truth is project.yml.
- Adding a file means adding it to project.yml, then running `xcodegen generate`.
- NEVER drag files into Xcode and commit the resulting .pbxproj diff.
- NEVER hand-edit the .pbxproj. If a change cannot be expressed in project.yml, update the spec generator first.
## Building and testing
- Build (iOS): `xcodebuild -scheme App-iOS -destination 'platform=iOS Simulator,name=iPhone 16'`
- Build (macOS): `xcodebuild -scheme App-macOS -destination 'platform=macOS'`
- Test: `xcodebuild test -scheme App-iOS -destination 'platform=iOS Simulator,name=iPhone 16'`
- Lint: `swift format lint --recursive Sources/ Tests/`
- Format: `swift format -i --recursive Sources/ Tests/`
## Hard rules
- NEVER touch the .pbxproj file directly. All project file changes go through project.yml + xcodegen.
- NEVER mix Combine and async/await for the same data flow. Pick one per file.
- ALL SPM dependencies pin to .upToNextMajor or .exact version. No branch refs in production.
- Every async function in UI code is annotated with @MainActor or explicitly hops via Task { @MainActor in }.
- xcodebuild must succeed for every supported platform target before the work is considered complete.
Three rules in this CLAUDE.md prevent the most common Claude Code failures with Swift.
The project file rule is the single most important. The .pbxproj is a plist-derived format with PBX-prefixed object types, UUIDs that must remain stable across regenerations, and ordering rules that are not enforced by Xcode but matter for diff stability. Claude has been trained on enough .pbxproj examples to write plausible-looking edits that nonetheless break the file. A subtle case: a new file reference added in one section without the corresponding build phase entry will compile fine until you hit the affected target, at which point the linker fails with an error message that does not point at the project file. Forcing all project file changes through XcodeGen (or the xcodeproj Ruby gem if you prefer that approach) means there is exactly one path that produces a valid .pbxproj, and Claude follows it.
The single concurrency model rule stops the most common Swift architectural drift. Combine and async/await solve overlapping problems and Apple has not deprecated either. When Claude is allowed to reach for whichever pattern is closer to hand, you get screens that subscribe to a Combine publisher inside an async function, asynchronous loading code that fires both an async Task and a Combine sink, and view models that store both AnyCancellable and Task references. Picking one per file is usually enough. For new code, async/await with AsyncSequence covers almost everything Combine was used for, and the structured concurrency error model is much easier to reason about. Combine remains the right choice in narrow cases (driving a SwiftUI .onReceive from a publisher exposed by a system framework, or wrapping a callback API where AsyncSequence is awkward) but those are now the exceptions, not the default.
The MainActor discipline rule prevents the entire class of "purple warnings" that appear in Xcode when UI code runs off the main thread. Swift 6 strict concurrency catches most of these at compile time, but not all of them. Claude will sometimes generate an async function that mutates @Published state without a main-actor hop, and the resulting runtime warning is easy to miss in development. The rule forces explicit annotation: any function that touches view state declares @MainActor, any function that does not is forbidden from touching view state. The compiler then enforces the rest.
Xcode project conventions: the .pbxproj problem
The .xcodeproj bundle is not a normal directory. It contains a .pbxproj file (the project graph), a workspace, scheme files, and user-state. Of those, only project.pbxproj and the scheme files are checked in. The .pbxproj is where targets, file references, build phases, build settings, and dependency graphs live. Apple has never published a stable spec for the format, the merge behaviour is hostile, and a corrupted .pbxproj cannot be opened by Xcode at all (you get "the project cannot be opened because the project file cannot be parsed").
There are three reasonable strategies for managing the .pbxproj across a team:
XcodeGen generates the entire .pbxproj from a YAML or JSON spec at project.yml. The repository checks in project.yml and the generated .xcodeproj is treated as a build artifact (often gitignored, regenerated on every checkout). This is the strictest pattern and the one with the cleanest Claude Code experience, because the spec is plain YAML that is easy to read, easy to diff, and impossible to corrupt.
Tuist is the heavier alternative. The spec is written in Swift, you get caching, project graphs across multiple modules, and a richer programming model. It is a stronger fit for a multi-module modular monolith with dozens of frameworks, and overkill for a single-app project.
xcodeproj gem is a Ruby library that mutates an existing .pbxproj programmatically. You keep the .xcodeproj checked in but use scripts to add files, targets, and dependencies. This is the migration path if you have an existing project that you cannot regenerate from scratch but want to stop hand-editing.
For a new project, XcodeGen is the recommended default. A minimal project.yml looks like this:
name: App
options:
deploymentTarget:
iOS: "17.0"
macOS: "14.0"
visionOS: "2.0"
bundleIdPrefix: tech.claudify.app
developmentLanguage: en
groupSortPosition: top
settings:
base:
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
ENABLE_USER_SCRIPT_SANDBOXING: YES
DEAD_CODE_STRIPPING: YES
targets:
App-iOS:
type: application
platform: iOS
sources:
- path: App/iOS
- path: Sources
dependencies:
- package: swift-collections
- package: swift-log
info:
path: App/iOS/Info.plist
properties:
UILaunchScreen: {}
CFBundleShortVersionString: "1.0.0"
App-macOS:
type: application
platform: macOS
sources:
- path: App/macOS
- path: Sources
dependencies:
- package: swift-collections
- package: swift-log
packages:
swift-collections:
url: https://github.com/apple/swift-collections
from: 1.1.0
swift-log:
url: https://github.com/apple/swift-log
from: 1.6.0
With this in place, adding a new feature is a two-step operation: create the Swift files in Sources/Features/NewFeature/, then run xcodegen generate. The .pbxproj is rebuilt deterministically. Claude can write the spec changes directly because the file is plain YAML, and there is no risk of corrupting the project.
The CLAUDE.md hard rule "NEVER touch the .pbxproj file directly" is what keeps this discipline alive. Without it, Claude will sometimes try to "help" by adding a PBXFileReference manually when a build fails. The result looks plausible and usually works once, and then breaks the next time you run xcodegen generate and the spec-derived file overwrites the manual edit.
SwiftUI patterns that hold up
SwiftUI is where Claude Code does its best Swift work, but only when the state model is explicit. The framework gives you @State, @StateObject, @ObservedObject, @EnvironmentObject, @Environment, @Bindable, and the newer @Observable macro. They are not interchangeable, the lifecycle rules are subtle, and using the wrong one is a common source of "the view does not update" bugs.
Add a SwiftUI patterns section to your CLAUDE.md:
## SwiftUI patterns
### State ownership
- @State for view-local value types (the view owns and mutates it)
- @Bindable for two-way bindings to @Observable types passed in from outside
- @Environment for app-wide singletons (theme, locale, services injected at root)
- @Observable (macro) replaces ObservableObject for new code; no @Published needed
### Use @Observable, not ObservableObject
@Observable is the default for view models in Swift 6. ObservableObject + @Published is legacy.
```swift
import Observation
@Observable
final class CartModel {
var items: [Product] = []
var isCheckingOut = false
func add(_ product: Product) {
items.append(product)
}
func remove(_ productID: Product.ID) {
items.removeAll { $0.id == productID }
}
}
View state pattern
struct ProductList: View {
@State private var query = ""
@Environment(CartModel.self) private var cart
@Environment(\.products) private var productsService
@State private var products: [Product] = []
@State private var isLoading = false
var body: some View {
List(filtered) { product in
ProductRow(product: product)
.swipeActions {
Button("Add") { cart.add(product) }
}
}
.searchable(text: $query)
.task { await load() }
}
private var filtered: [Product] {
guard !query.isEmpty else { return products }
return products.filter { $0.name.localizedCaseInsensitiveContains(query) }
}
@MainActor
private func load() async {
isLoading = true
defer { isLoading = false }
products = (try? await productsService.fetchAll()) ?? []
}
}
View extraction threshold
Any body that exceeds 60 lines or has more than three nested closures gets extracted into a private subview struct.
Avoid AnyView
AnyView erases type information and disables SwiftUI's diffing. Use @ViewBuilder or generic constraints instead.
The @Observable rule matters because the older ObservableObject pattern requires @Published on every property and triggers a view update on any change to any @Published property. The new @Observable macro tracks reads at the view level: a view that only reads `cart.items.count` rebuilds only when that value changes, not when `isCheckingOut` flips. Claude defaults to @Observable when the rule is in CLAUDE.md, which significantly cuts the rebuild count on real screens.
The view extraction threshold is what keeps generated code reviewable. SwiftUI's body property tends to grow as features accumulate, and a body with twelve nested HStacks and four conditional modifiers becomes impossible to follow. The 60-line threshold catches genuinely large views without forcing extraction of trivial trees. Claude obeys it consistently when it is in CLAUDE.md.
The AnyView rule deserves emphasis. AnyView is occasionally the right answer (returning different concrete view types from a single function) but Claude reaches for it more often than it should because it solves type-erasure errors quickly. The cost is real: SwiftUI's diffing relies on stable view identity, and AnyView throws that away. The alternative is almost always a generic function with a `@ViewBuilder` parameter, or a switch over an enum that returns different views in each branch.
## SwiftUI vs UIKit: when each wins
SwiftUI is the default for new work in 2026, but it does not yet cover every API surface. The decision matrix is:
**SwiftUI** for: every new screen on iOS 17+, macOS 14+, and visionOS. Forms, lists, settings panels, modal sheets, navigation stacks, and most onboarding flows are dramatically faster to build in SwiftUI and easier for Claude to maintain.
**UIKit** (or AppKit) for: complex collection views with custom cell prefetching, anything that needs UIScrollView delegate behaviour beyond what ScrollViewReader exposes, custom drawing with CAShapeLayer or Metal, integration with UIViewController-based system APIs (UIDocumentPickerViewController, UIImagePickerController in some configurations), and any screen with non-trivial gesture composition.
When a SwiftUI screen needs a UIKit component, wrap it in a UIViewRepresentable or UIViewControllerRepresentable. The wrapper is the boundary, and the rest of the screen stays in SwiftUI. Add the rule to CLAUDE.md:
```markdown
## UIKit interop
- New screens default to SwiftUI.
- UIKit only when SwiftUI cannot reach the required API.
- UIKit components inside a SwiftUI screen go through UIViewRepresentable / UIViewControllerRepresentable.
- The Representable wrapper lives in the same feature folder, named {Feature}KitWrapper.swift.
- Coordinator types use weak parent references and forward delegate callbacks via @Binding.
Concurrency: async/await first, Combine when
Swift's structured concurrency model has matured enough that it is the default for new work. Async functions, actors, AsyncSequence, and TaskGroup cover the same territory as Combine with simpler error handling and clearer cancellation semantics. The rule of thumb is: if you can write it as async throws -> T, write it that way.
Add to CLAUDE.md:
## Concurrency
### Default: async/await
```swift
func fetchProducts() async throws -> [Product] {
let url = URL(string: "https://api.example/products")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Product].self, from: data)
}
Streams: AsyncSequence
For continuous values, use AsyncSequence rather than Combine publishers.
extension URLSession {
func bytesAsLines(from url: URL) async throws -> AsyncLineSequence<URLSession.AsyncBytes> {
let (bytes, _) = try await self.bytes(from: url)
return bytes.lines
}
}
Actors for shared mutable state
Replace classes with locks with actors. The compiler enforces isolation.
actor RatingsCache {
private var cache: [Product.ID: Double] = [:]
func value(for id: Product.ID) -> Double? {
cache[id]
}
func store(_ rating: Double, for id: Product.ID) {
cache[id] = rating
}
}
Combine: only for these cases
- Bridging NotificationCenter / KVO into reactive streams
- Existing codebases with mature Combine pipelines (do not migrate for the sake of it)
- SwiftUI .onReceive bindings to system-provided publishers
Task lifetime
- Use .task(id:) modifier to bind a Task to a view's lifetime
- Use Task { @MainActor in ... } only for fire-and-forget UI updates
- NEVER store Task references in @State unless you also cancel them in onDisappear
The Task lifetime rule matters because SwiftUI's `.task` modifier handles cancellation automatically: when the view disappears, the task is cancelled. Claude sometimes generates `Task { ... }` inside `.onAppear` instead, which leaks if the view disappears mid-flight. Forcing the `.task` modifier as the default in CLAUDE.md eliminates that class of bug.
The actor rule replaces a long history of NSLock and DispatchQueue.sync code that Claude has seen in training. Modern Swift code does not need explicit locking for shared mutable state. Actors give you the same isolation with compile-time checks that prevent reentrancy bugs. When Claude is told "actors for shared mutable state", it stops generating manual lock dances and writes idiomatic Swift 6 concurrency.
## Swift Package Manager: dependency discipline
SPM is now the default dependency tool for Apple-platform projects. CocoaPods is in maintenance mode and Carthage is rarely the right answer for new work. SPM resolves into the .xcodeproj as Package Dependencies and into Package.swift for non-app modules.
Add to CLAUDE.md:
```markdown
## SPM dependencies
### Pinning
- All package dependencies use .upToNextMajor or .exact. No .branch in production.
- Package.swift goes through code review like any other source file.
- Run `swift package update` deliberately, never as a side effect of unrelated work.
- Lockfile (Package.resolved) is checked in. Do not regenerate without intent.
### Approved packages (do not add others without explicit approval)
- swift-collections (Apple)
- swift-log (Apple)
- swift-async-algorithms (Apple)
- KeychainAccess (kishikawakatsumi)
- Nuke (kean), image loading
- Sentry (getsentry), crash reporting
A minimal Package.swift for a Swift package target looks like:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Features",
platforms: [
.iOS(.v17),
.macOS(.v14),
.visionOS(.v2),
],
products: [
.library(name: "Features", targets: ["Features"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-collections", .upToNextMajor(from: "1.1.0")),
.package(url: "https://github.com/apple/swift-log", .upToNextMajor(from: "1.6.0")),
],
targets: [
.target(
name: "Features",
dependencies: [
.product(name: "Collections", package: "swift-collections"),
.product(name: "Logging", package: "swift-log"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]
),
.testTarget(
name: "FeaturesTests",
dependencies: ["Features"]
),
]
)
The approved-packages list is the highest-leverage rule in this section. Without it, Claude reaches for whichever package solves the immediate problem, and over time a Swift project accumulates three different image loaders, two networking libraries, and a half-dozen utility packages with overlapping functionality. Pinning the list keeps the dependency graph small and the binary size predictable.
The .upToNextMajor rule is the right default. It accepts patch and minor updates automatically (which is what you want for security fixes) but blocks majors, which usually contain breaking API changes. .exact is appropriate for packages that have a history of breaking on minor releases (some logging and crash-reporting libraries fall into this category). .branch is never appropriate for production unless you are temporarily testing a fix.
XCTest, Swift Testing, and the build pipeline
Apple platforms have two testing frameworks now. XCTest is the long-standing one with extensive system-level integration (UI tests, XCUITest, performance metrics). Swift Testing is the newer macro-based framework with cleaner syntax, parameterised tests, and tag-based filtering. New test files should use Swift Testing; existing XCTest code does not need to migrate.
Add to CLAUDE.md:
## Testing
### Swift Testing for new tests
```swift
import Testing
@testable import Features
@Suite("CartModel")
struct CartModelTests {
@Test func addingProductIncrementsItems() {
let cart = CartModel()
let product = Product(id: "1", name: "Widget", price: 9.99)
cart.add(product)
#expect(cart.items.count == 1)
#expect(cart.items.first?.id == "1")
}
@Test(arguments: [1, 5, 10])
func removingProductLeavesRemainder(count: Int) {
let cart = CartModel()
let products = (0..<count).map { Product(id: "\($0)", name: "P\($0)", price: 1) }
products.forEach { cart.add($0) }
cart.remove("0")
#expect(cart.items.count == count - 1)
}
}
XCTest for UI / system integration
import XCTest
final class CartUITests: XCTestCase {
func testAddingProductFromList() throws {
let app = XCUIApplication()
app.launch()
app.staticTexts["Widget"].swipeLeft()
app.buttons["Add"].tap()
XCTAssert(app.tabBars.buttons["Cart"].label.contains("1"))
}
}
Test conventions
- Unit tests live next to the code they test, in Tests/{Module}Tests/
- UI tests live in App/iOS/UITests/ (one target per platform)
- Snapshot tests use swift-snapshot-testing, with the default record mode OFF
- Every public type has at least one @Test exercising the happy path
The build pipeline itself needs explicit configuration so Claude can run it from CLI and report on real failures rather than guessing. The xcodebuild incantation matters because the destination string differs across simulators and physical devices, and an incorrect destination produces an opaque error message.
Add to CLAUDE.md:
```markdown
## Build / test workflow
### Daily development
# Format + lint before any commit
swift format -i --recursive Sources/ Tests/
swift format lint --recursive Sources/ Tests/
# Generate the project file (only if project.yml changed)
xcodegen generate
# Build for iOS simulator
xcodebuild build \
-scheme App-iOS \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
-derivedDataPath .build/derived
# Run all tests on iOS simulator
xcodebuild test \
-scheme App-iOS \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
-derivedDataPath .build/derived
# Build for macOS
xcodebuild build \
-scheme App-macOS \
-destination 'platform=macOS,arch=arm64'
### When the build breaks
1. `rm -rf .build/derived ~/Library/Developer/Xcode/DerivedData/App-*`
2. `xcodegen generate` (if project.yml changed)
3. `xcodebuild -resolvePackageDependencies -scheme App-iOS`
4. Retry the build command.
5. If still broken, open Xcode and Product > Clean Build Folder (cmd+shift+K), then retry from CLI.
The DerivedData wipe is the equivalent of flutter clean for Apple platforms. SPM resolution, module caches, and Swift module interfaces accumulate stale state in DerivedData that survives across builds, and the symptoms are non-obvious. Claude will retry a failing build indefinitely without clearing DerivedData unless the recipe is in CLAUDE.md. Once it is, the recovery sequence becomes deterministic. Apple's Xcode build system documentation covers the underlying mechanics for cases that survive a DerivedData wipe.
Hard rules: the conclusion
A working Swift project with Claude Code on board needs four things in the CLAUDE.md to stay healthy. One project file strategy, with the .pbxproj treated as a build artifact and all changes flowing through XcodeGen or xcodeproj. One UI framework default, with SwiftUI everywhere except where it cannot reach the API. One concurrency model per file, with async/await as the default and Combine reserved for the narrow cases that need it. One dependency policy, with SPM packages pinned and a curated list of approved libraries.
The patterns in this guide produce a Swift codebase where SwiftUI views compose cleanly, state lives in @Observable models with the right granularity, project file changes are deterministic and reviewable, dependencies are pinned and auditable, and the build pipeline has a known recovery path on every supported platform. That is what gives Claude Code enough scaffolding to ship features without breaking the .pbxproj or shipping non-compiling Swift.
If you have not configured CLAUDE.md across your projects yet, the CLAUDE.md explainer covers the file format, precedence rules, and which sections matter most. The same principles apply across the rest of the mobile and desktop ecosystem: the Claude Code with Flutter guide covers the equivalent setup for cross-platform mobile, the Claude Code with React Native guide covers the JavaScript-based mobile stack, and the Claude Code best practices guide covers the configuration habits that apply across all framework choices.
Two further pieces are worth mentioning. The Claude Code testing guide covers the testing layer in more depth, including how to structure XCTest and Swift Testing files consistently. The Claude Code Mac setup guide covers the system-level configuration for Apple-platform development.
The principle is the same one that runs through every framework integration: Claude Code performs at the level of the context you give it. A Swift project with no CLAUDE.md produces .pbxproj corruption, mixed concurrency models, unpinned SPM packages, and screens that refuse to update because the wrong property wrapper was used. A project with the configuration above produces Claude that follows your conventions from the first view, generates project spec changes alongside the source code, and runs xcodebuild clean across iOS, macOS, and visionOS. Claudify includes a Swift-specific CLAUDE.md template as part of the workflow kit, pre-configured for SwiftUI, XcodeGen, async/await, and the SPM patterns covered above.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify