Ask Claude Code to "add a screen that loads a user profile and shows their orders" and the output compiles. It also strong-captures self in three closures, blocks the main thread on a network call, mutates @State from a background actor, and leaks a coordinator. Nothing crashes in the simulator. Everything crashes in TestFlight.
Swift is hostile to AI defaults: ARC, the SwiftUI lifecycle, Combine, structured concurrency, and a decade of UIKit history coexist in the same target. Half the model's training data is Objective-C, half is pre-async/await Swift, and the rest is SwiftUI tutorials that ignore actor isolation. A CLAUDE.md next to Package.swift is the cheapest way to pull it back to current Apple practice.
Get the full CLAUDE.md Rules Pack — oliviacraftlat.gumroad.com/l/skdgt. The 13 rules below are a free preview.
1. [weak self] in every escaping closure that touches self
ARC retain cycles are the canonical iOS leak, and AI writes them constantly: a closure captured by a long-lived publisher, Task, URLSession handler, or NotificationCenter observer that strongly references self. Every @escaping closure that mentions self opens with [weak self] in and unwraps via guard let self else { return }.
viewModel.$user
.sink { [weak self] user in
guard let self else { return }
self.titleLabel.text = user.name
}
.store(in: &cancellables)
Non-escaping closures (map, filter, forEach) don't need it — capture is bounded by the call.
2. Force-unwrap ! and try! are banned outside tests
user.profile!.name and try! JSONDecoder().decode(...) are the top crash signature in production iOS apps. Use guard let, if let, optional chaining, ??, or do/try/catch. Reserve ! for IBOutlet and test setup where failure is the test failing.
guard let url = URL(string: endpoint) else {
throw APIError.invalidURL(endpoint)
}
let (data, _) = try await URLSession.shared.data(from: url)
let user = try JSONDecoder().decode(User.self, from: data)
fatalError is allowed only for genuinely impossible states, with a message explaining why.
3. async/await only — no completion handlers in new code
If the deployment target is iOS 15+, every new async API returns async throws or uses AsyncSequence. Completion-handler closures leak callbacks, scatter error handling, and don't compose with Task cancellation. Bridge legacy APIs once via withCheckedThrowingContinuation and never again.
func loadOrders(for userID: UUID) async throws -> [Order] {
let request = try ordersRequest(userID: userID)
let (data, response) = try await session.data(for: request)
try OrdersAPI.validate(response)
return try decoder.decode([Order].self, from: data)
}
No DispatchQueue.global().async { DispatchQueue.main.async { ... } } pyramids.
4. UI mutations run on @MainActor, full stop
Updating UIKit views or @State from a background context is a Swift 6 concurrency error and a flicker bug today. Annotate view models with @MainActor so the compiler enforces it. AI defaults to DispatchQueue.main.async — which works, but skips the actor isolation guarantee and breaks under strict concurrency.
@MainActor
final class OrdersViewModel: ObservableObject {
@Published private(set) var orders: [Order] = []
func refresh() async {
do { orders = try await api.loadOrders() }
catch { /* surface to UI */ }
}
}
Heavy decoding or image work goes in a non-isolated helper (or Task.detached) and the result is awaited back on the main actor.
5. Pick the right SwiftUI property wrapper, once
@State, @StateObject, @ObservedObject, @Binding, @Environment are not interchangeable, but AI uses them as if they were:
-
@State— value types owned by this view. -
@StateObject— reference-type view models created by this view. -
@ObservedObject— view models passed in from a parent. -
@Binding— two-way value bindings from a parent. -
@Environment/@EnvironmentObject— dependencies injected up the tree.
struct OrdersScreen: View {
@StateObject private var viewModel = OrdersViewModel() // owned here
var body: some View {
OrdersList(viewModel: viewModel)
}
}
struct OrdersList: View {
@ObservedObject var viewModel: OrdersViewModel // passed in
var body: some View { /* ... */ }
}
Wrong wrapper = state thrown away on parent re-render, lifecycle bugs only visible in TestFlight.
6. Shared mutable state lives in an actor
Whenever AI writes private let queue = DispatchQueue(label: "...") to synchronize access, replace it with an actor. Locks (NSLock, os_unfair_lock) are reserved for non-async hot paths or C interop.
actor ImageCache {
private var storage: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? { storage[url] }
func store(_ image: UIImage, for url: URL) { storage[url] = image }
}
Calls become await cache.image(for: url). Data races become compile errors, not Sentry alerts.
7. Errors are typed enums conforming to LocalizedError
throw NSError(domain: "...", code: -1) is AI that gave up. Stable error conditions get an enum case; errors that cross boundaries conform to LocalizedError so they're safe to surface in UI.
enum APIError: Error, LocalizedError {
case invalidURL(String)
case http(status: Int)
case decoding(underlying: Error)
var errorDescription: String? {
switch self {
case .invalidURL(let s): return "Invalid URL: \(s)"
case .http(let code): return "Server error (\(code))"
case .decoding: return "Couldn't read server response"
}
}
}
Callers branch on cases, not string matching.
8. Protocols + struct value types — inheritance is the last resort
AI loves class hierarchies — BaseViewController, BaseViewModel, AbstractRepository. Swift prefers protocol-oriented composition and value-type models. Use class only for real reference semantics (long-lived VMs, ARC observers, UIKit subclasses).
protocol OrdersRepository {
func loadOrders(for userID: UUID) async throws -> [Order]
}
struct LiveOrdersRepository: OrdersRepository { /* ... */ }
struct StubOrdersRepository: OrdersRepository { /* tests */ }
Domain models (Order, User, Money) are struct with Codable/Equatable/Sendable where applicable.
9. SwiftUI views are small, dumb, and pure
A View body that scrolls past one screen is doing too much. Extract @ViewBuilder helpers, child views, and ViewModifiers; keep state and side effects in the view model. AI defaults to god-views with inline networking — that path produces non-deterministic previews and untestable UI.
struct OrderRow: View {
let order: Order
var body: some View {
HStack {
Text(order.title).font(.body)
Spacer()
Text(order.total.formatted(.currency(code: "USD")))
.monospacedDigit()
}
.padding(.vertical, 8)
}
}
No network, no @StateObject, no business logic in row views. Previews work offline.
10. UIKit interop: UIViewRepresentable for one widget, not whole flows
When something has to drop to UIKit (camera, mail composer, third-party SDK), wrap that one view in UIViewRepresentable or UIViewControllerRepresentable. AI wraps entire flows and reinvents navigation inside SwiftUI — that produces double nav stacks, broken back buttons, and coordinators that outlive their views.
struct ScannerView: UIViewControllerRepresentable {
let onResult: (String) -> Void
func makeUIViewController(context: Context) -> ScannerViewController {
let vc = ScannerViewController()
vc.onResult = onResult
return vc
}
func updateUIViewController(_ vc: ScannerViewController, context: Context) {}
}
Coordinator types exist only when you need a delegate target — and they hold self weakly.
11. Dependency injection via initializer — no singletons, no Resolver
UserDefaults.standard, URLSession.shared, and homemade ServiceLocators are the AI's go-to dependencies and they make tests impossible. Inject collaborators through the initializer; default the parameter for production. Sub them out for tests and previews.
@MainActor
final class OrdersViewModel: ObservableObject {
private let repository: OrdersRepository
init(repository: OrdersRepository = LiveOrdersRepository()) {
self.repository = repository
}
}
@Environment carries cross-cutting concerns (theme, feature flags); everything else is constructor-injected.
12. Tests cover async paths with XCTest's async APIs — no XCTestExpectation for new code
XCTestExpectation and wait(for:timeout:) are how you tested completion handlers in 2019. New tests await directly. Use XCTUnwrap instead of !, snapshot tests for view layouts, and mock at the protocol boundary — not at URLSession.
@MainActor
final class OrdersViewModelTests: XCTestCase {
func test_refresh_populatesOrders() async throws {
let repo = StubOrdersRepository(orders: .sample)
let sut = OrdersViewModel(repository: repo)
await sut.refresh()
XCTAssertEqual(sut.orders.count, 3)
}
}
CI runs xcodebuild test -enableThreadSanitizer YES on a matrix including the lowest supported iOS version.
13. Build hygiene: SwiftPM, SwiftLint, strict concurrency on
Dependencies live in Package.swift, pinned to exact or up-to-next-minor versions. SwiftLint runs in a build phase with project rules. Turn on -strict-concurrency=complete before Swift 6 forces you to. AI will add CocoaPods, disable warnings, and set SWIFT_TREAT_WARNINGS_AS_ERRORS=NO "to ship" — that's how 200-warning codebases happen.
// Package.swift
.package(url: "https://github.com/apple/swift-collections", from: "1.1.0"),
# .swiftlint.yml
opt_in_rules:
- force_unwrapping
- implicit_return
- explicit_init
disabled_rules:
- line_length
Warnings are errors. Force-unwrap is a lint failure. Strict concurrency catches the @MainActor violation before TestFlight does.
A starter CLAUDE.md snippet
# CLAUDE.md — iOS app
## Stack
- Swift 5.10+, iOS 16+, SwiftUI primary, UIKit only via Representable
- async/await, actors, Combine for legacy publishers, SwiftPM, XCTest
## Hard rules
- `[weak self]` + `guard let self else { return }` in every escaping closure that uses self.
- No `!`, no `try!` outside tests and IBOutlets. Use `guard let` / `do try catch`.
- New async APIs are `async throws` or `AsyncSequence`. Bridge legacy via continuation once.
- All UI mutations run on `@MainActor`. Heavy work in detached tasks, await result back.
- Pick the right wrapper: `@State` value-owned, `@StateObject` view-owned VM, `@ObservedObject` injected VM.
- Shared mutable state = `actor`. Locks only for non-async hot paths.
- Errors are typed enums conforming to `LocalizedError`. No `NSError(domain:)`.
- Protocols + `struct` first. `class` only for reference semantics.
- SwiftUI views: small, pure, no network, previews must work offline.
- UIKit via `UIViewRepresentable` for one widget, not whole flows.
- DI via initializer. No singletons, no service locators.
- Tests use async/await, `XCTUnwrap`, mock at protocol boundary, ThreadSanitizer in CI.
- SwiftPM only. SwiftLint enforced. Strict concurrency on. Warnings = errors.
What Claude gets wrong without these rules
- Strong-captures
selfin Combine sinks → view models leak forever. - Force-unwraps decoded JSON → first malformed payload crashes the app.
- Updates
@Publishedfrom aURLSessioncallback → flickers, Swift 6 errors. - Uses
@ObservedObjectinstead of@StateObject→ state resets on parent rerender. - Wraps a UIKit flow in
UIViewControllerRepresentable→ double back buttons. - Reaches for
URLSession.sharedeverywhere → repository is impossible to test.
Drop the 13 rules above into CLAUDE.md and the next AI PR looks like an iOS app, not a SwiftUI tutorial. TestFlight stops paging you.
Want this for 20+ stacks with 200+ rules ready to paste? Grab the CLAUDE.md Rules Pack at oliviacraftlat.gumroad.com/l/skdgt.
— Olivia (@OliviaCraftLat)
Top comments (0)