Swift 6 language mode turns data-race risks into compiler errors, mostly about sendability. The practical strategy: sort your types into four buckets and give each bucket one concurrency tool.
Data types: immutable Sendable structs
What cannot mutate cannot race. Model data as structs with let properties. Internal structs
infer Sendable; a public struct must declare it explicitly.
public struct Statistics: Sendable, Hashable {
public let value: Double
public let interval: DateInterval
}
Stateless services: structs too
A service that holds no mutable state, only a handle it reads through, can also be a
Sendable struct and cross threads freely.
public struct HealthService: Sendable {
private let store: HKHealthStore
public var age: Int {
// derive from store.dateOfBirthComponents()
}
}
View-facing classes: isolate to @MainActor
Use classes only where state must be shared without copying, typically view models and
delegates. Annotating them @MainActor makes them implicitly sendable because a global actor
serializes all access.
@MainActor @Observable final class Store<State, Action> {
private(set) var state: State
private let reduce: (State, Action) -> State
func send(_ action: Action) {
state = reduce(state, action)
}
}
Stateful services: actors
A service that both holds state and is called from many threads, such as a search service with a
cache, is the real data-race source. The actor keyword gives it mutually exclusive access.
actor SearchService {
private var history = Tree<Food>()
func search() async throws -> [Food] {
// read history, fetch, mutate history
}
}
The symptom this whole scheme prevents: a class shared across threads, one of them writing, and
the app dying with EXC_BAD_ACCESS.