Why use actors and Sendable values in our code?
import Foundation
/// URL loading suspends; decoding stays on this actor, outside the Main Actor.
actor RocketLaunchAPI: LaunchRepository {
private let session: URLSession
private let endpoint: URL
init(session: URLSession, endpoint: URL) {
self.session = session
self.endpoint = endpoint
}
func fetchUpcomingLaunches() async throws -> [RocketLaunch] {
try Task.checkCancellation()
let (data, response) = try await session.data(from: endpoint)
try Task.checkCancellation()
guard let response = response as? HTTPURLResponse else {
throw LaunchRepositoryError.invalidResponse
}
guard (200..<300).contains(response.statusCode) else {
throw LaunchRepositoryError.httpStatus(response.statusCode)
}
let launches: [RocketLaunch]
do { launches = try Self.decodeResponse(data) }
catch { throw LaunchRepositoryError.invalidData }
try Task.checkCancellation()
return launches
}
private static func parseLaunchTime(_ value: String) throws -> Date {
let iso = ISO8601DateFormatter()
if let date = iso.date(from: value) { return date }
iso.formatOptions.insert(.withFractionalSeconds)
if let date = iso.date(from: value) { return date }
let minutes = DateFormatter()
minutes.locale = Locale(identifier: "en_US_POSIX")
minutes.timeZone = TimeZone(secondsFromGMT: 0)
minutes.dateFormat = "yyyy-MM-dd'T'HH:mmXXXXX"
minutes.isLenient = false
if let date = minutes.date(from: value) { return date }
throw LaunchRepositoryError.invalidData
}
/// Shared by the production repository and its fixture regression tests.
static func decodeResponse(_ data: Data) throws -> [RocketLaunch] {
try JSONDecoder().decode(Response.self, from: data).result.map { payload in
RocketLaunch(id: payload.id, name: payload.name,
missions: payload.missions.map { LaunchMission(name: $0.name, description: $0.description) },
estimatedDate: EstimatedLaunchDate(month: payload.estimatedDate.month,
day: payload.estimatedDate.day,
year: payload.estimatedDate.year),
details: LaunchDetails(provider: payload.provider?.name, vehicle: payload.vehicle?.name,
country: payload.pad?.location?.country, site: payload.pad?.location?.name,
plannedTime: try payload.t0.map(Self.parseLaunchTime),
sortTime: payload.sortDate.flatMap(Double.init).map { Date(timeIntervalSince1970: $0) },
estimatedDateLabel: payload.dateLabel, missionDescription: payload.missionDescription,
detailsURL: payload.slug.flatMap { launchWebURL("https://www.rocketlaunch.live/launch/" + $0) }))
}
}
}
// Decode only fields owned by this feature. Optional date components remain
// unknown, and genuinely malformed values still fail decoding.
private struct Response: Decodable {
let result: [LaunchPayload]
}
private struct LaunchPayload: Decodable {
let id: Int
let name: String
let missions: [MissionPayload]
let estimatedDate: DatePayload
let provider: NamedPayload?
let vehicle: NamedPayload?
let pad: PadPayload?
let t0: String?
let sortDate: String?
let slug: String?
let dateLabel: String?
let missionDescription: String?
enum CodingKeys: String, CodingKey {
case id, name, missions, provider, vehicle, pad, t0, slug
case sortDate = "sort_date"
case dateLabel = "date_str"
case missionDescription = "mission_description"
case estimatedDate = "est_date"
}
}
private struct MissionPayload: Decodable {
let name: String
let description: String?
}
private struct DatePayload: Decodable {
let month: Int?
let day: Int?
let year: Int?
}
private struct NamedPayload: Decodable { let name: String? }
private struct PadPayload: Decodable { let location: LocationPayload? }
private struct LocationPayload: Decodable { let name: String?; let country: String? }This is the complete RocketLaunch.Live provider implementation from Next Rocket Launch, including the private types used to decode its response. The actor owns the request and decoding boundary. It returns domain values rather than exposing a mutable decoder or partially processed response to the UI.
Actors and Sendable answer two questions: who may access isolated state, and what can safely cross a concurrency boundary? They do not answer a third question: what should the application do when two people—or two screens—request the same operation?
1. The approach
func fetchUpcomingLaunches() async throws -> [RocketLaunch] {
try Task.checkCancellation()
let (data, response) = try await session.data(from: endpoint)
try Task.checkCancellation()
guard let response = response as? HTTPURLResponse else {
throw LaunchRepositoryError.invalidResponse
}
guard (200..<300).contains(response.statusCode) else {
throw LaunchRepositoryError.httpStatus(response.statusCode)
}
let launches: [RocketLaunch]
do { launches = try Self.decodeResponse(data) }
catch { throw LaunchRepositoryError.invalidData }
try Task.checkCancellation()
return launches
}Read this method in execution order. It checks cancellation, awaits the HTTP request, checks cancellation again, validates the response, decodes it and returns the launches. The checks and errors make the possible outcomes explicit. A failed response is not replaced with sample launches.
The network await permits suspension. It does not reserve a thread or CPU core for this actor. Decoding is synchronous work in this provider; the caller awaits the provider boundary rather than performing that decoding in a View. In these pinned sources the project uses Swift 6 with explicit isolation and nonisolated default settings; compiler settings remain part of the example’s context.
The default boundary is deliberate: Features coordinate authoritative application state on MainActor; networking, persistence and expensive processing belong in services below that boundary. A NetworkingManager can be an actor that protects its session-related bookkeeping. A worker actor can also own ordinary helper types; every helper does not need its own actor.
Placing a type in a service folder does not move its execution. A synchronous helper runs where its caller runs, and async isolation depends on its declaration and the project’s compiler settings. Keep trivial coordination cheap; cross an explicit worker boundary for expensive work. An actor provides isolation, not a dedicated background thread. Swift’s async isolation proposal explains why async alone is not a guarantee of leaving MainActor.
CFA’s provider rule is also specific: each external service has its own provider owner. Adding a second launch service should not turn this type into a generic container for unrelated networking and storage.
2. The problem it solves
struct RocketLaunch: Identifiable, Equatable, Sendable, Codable {
let id: String
let source: LaunchSourceID
let name: String
let missions: [LaunchMission]
let estimatedDate: EstimatedLaunchDate
let details: LaunchDetails
init(id: Int, name: String, missions: [LaunchMission], estimatedDate: EstimatedLaunchDate, details: LaunchDetails = LaunchDetails()) {
self.init(id: String(id), source: .rocketLaunchLive, name: name, missions: missions, estimatedDate: estimatedDate, details: details)
}
init(id: String, source: LaunchSourceID, name: String, missions: [LaunchMission], estimatedDate: EstimatedLaunchDate, details: LaunchDetails) {
self.id = source.rawValue + ":" + id; self.source = source
self.name = name; self.missions = missions
self.estimatedDate = estimatedDate; self.details = details
}
var primaryMissionDescription: String? { missions.first?.description }
}This domain value contains the launch’s identity, source, name, missions, estimated date and details. Its Sendable conformance makes the intended boundary visible. The contained types must satisfy that safety contract too; writing the keyword is not permission to share arbitrary mutable objects.
The UI does not need access to the provider’s session or decoder. It needs a launch value it can interpret and display. That keeps transport-specific details on one side and application information on the other.
However, a Sendable result says nothing about whether it is still the result the application wants. Two safe values can arrive in an inconvenient order. Swift’s concurrency guide distinguishes isolation and suspension; CFA adds an explicit operation policy on top of those language guarantees.
3. Why it works
private func registerRefresh(source: LaunchSourceID, waiterID: UUID, continuation: CheckedContinuation<Void, Never>) {
guard !Task.isCancelled,
let index = sources.firstIndex(where: { $0.id == source }),
let configuration = configurations.first(where: { $0.id == source }) else { continuation.resume(); return }
// Joining existing work does not make another HTTP request or bypass cooldown.
if requests[source] != nil {
requests[source]?.waiters[waiterID] = continuation
return
}
if let retryAt = sources[index].nextRefreshAt, now() < retryAt { continuation.resume(); return }
let previous = sources[index]
let id = UUID()
sources[index].phase = .loading
sources[index].nextRefreshAt = now().addingTimeInterval(configuration.minimumRefreshInterval)
updateNextLaunch()
let repository = configuration.repository
let task = Task { [weak self] in
let result: Result<[RocketLaunch], any Error>
do {
let launches = try await repository.fetchUpcomingLaunches()
try Task.checkCancellation()
result = .success(launches)
} catch { result = .failure(error) }
await self?.completeRefresh(source: source, id: id, result: result)
}
requests[source] = Request(id: id, task: task, previous: previous, waiters: [waiterID: continuation])
}This method belongs to the app’s schedule feature. A request is identified by provider. If that provider already has work in progress, a new caller joins it as a waiter. Otherwise the feature records the previous snapshot, creates a request identity and starts the provider operation.
The method has no suspension while it makes that ownership decision. The asynchronous work happens inside the retained task. This prevents two equivalent requests from independently deciding that there is no active request before either has recorded one.
private func completeRefresh(source: LaunchSourceID, id: UUID, result: Result<[RocketLaunch], any Error>) {
guard let request = requests[source], request.id == id,
let index = sources.firstIndex(where: { $0.id == source }) else { return }
var effects: [ReminderEffect] = []
switch result {
case .success(let launches):
recordChanges(from: sources[index].launches, to: launches)
sources[index].launches = launches
sources[index].fetchedAt = now()
sources[index].phase = .loaded
rebuildOperators()
// Cache and desired reminders change in one uninterrupted actor operation.
effects = reconcileReminders(with: launches)
case .failure(let error):
if error is CancellationError || (error as? URLError)?.code == .cancelled {
sources[index] = request.previous
} else { sources[index].phase = .failed(Self.failure(for: error)) }
}
updateNextLaunch()
// Download ownership ends with the atomic model commit. Notification delivery
// has its own lifetime and cannot keep subsequent refreshes joined to old data.
requests[source] = nil
startReminderEffects(effects)
for waiter in request.waiters.values { waiter.resume() }
}Completion checks the request identity before committing data. A result from an old operation cannot simply overwrite the currently owned request. The method updates the model, ends request ownership and resumes its waiters. These are application decisions; the actor keyword alone does not supply them.
This older schedule feature is itself an actor and uses snapshot streams. Current CFA places every Feature that owns authoritative application state on MainActor, including Features without a current screen. Providers and storage workers can remain separate actors. Their internal requests, caches and database machinery support the capability; they do not become a second authority for its application state. The request-identity lesson applies across either arrangement; do not copy the old feature protocol or stream design merely to adopt CFA.
4. Advantages of using it
private func cancelRefresh(source: LaunchSourceID, waiterID: UUID) {
guard var request = requests[source], let waiter = request.waiters.removeValue(forKey: waiterID) else { return }
if request.waiters.isEmpty {
// Detach ownership now; a new screen need not wait for canceled transport.
requests[source] = nil
request.task.cancel()
if let index = sources.firstIndex(where: { $0.id == source }) { sources[index] = request.previous }
updateNextLaunch()
} else { requests[source] = request }
waiter.resume()
}Cancellation also has an owner. Removing one waiter does not cancel work still needed by another. When the last waiter leaves, this implementation detaches the request, requests task cancellation, restores the previous source state and resumes the caller.
CFA’s documented rationale: isolated state, boundary values and overlapping-call policy must all be explicit. Editorial trade-off: identities and waiter bookkeeping add complexity, so use them where replacement and independent cancellation genuinely require them. A simple single-flight operation that never replaces active work may need only one retained task.
Review these three layers separately: safe access, safe values and correct outcomes. Then test duplicate requests, cancellation and stale completion with controlled dependencies. A compiler-clean program can still publish the wrong result if its operation policy is missing.
Try this with the CFA AI Toolkit for iOS:
Using CFA for iOS, review isolation, boundary values and overlapping-operation policy in this code.
Scope: code from the linked app revisions; smaller excerpts belong to their containing types and targets. The articles distinguish older implementations from current CFA conventions. Source verification is not a full app build or device test. Suggested checks are guidance, not claims they ran against your application.