Blog › ICP guides

Swift developer on retainer: Swift concurrency, type system, SwiftUI, SwiftData, and testing on monthly retainer

August 21, 2026 · ~22 min read

A startup’s iOS app had a networking layer built entirely on DispatchQueue and completion handlers. A DataSyncManager.shared singleton coordinated background data synchronization using a private serial queue, but a race condition between the sync queue and the main queue caused crashes on object deallocation: the singleton’s internal array was being mutated from two concurrent dispatch blocks that had both captured the same DispatchQueue.async submission window. Crashlytics showed 12 crashes per week, all in DataSyncManager, all EXC_BAD_ACCESS. The engineers had disabled Thread Sanitizer in the scheme settings months earlier because it slowed builds. A fractional Swift architect on monthly retainer re-enabled TSan on day one: it immediately reported three data races. The fix — replacing the singleton with an actor — serialized all access to shared mutable state through Swift’s cooperative thread pool without a single lock. The crashes dropped from 12 per week to zero in the first two weeks after the release.

The second month’s retainer work addressed SwiftUI view identity issues causing laggy list performance. A List displaying 300 workout records scrolled at 38fps because the ForEach was keyed by array index rather than by a stable Identifiable identifier — every data refresh triggered a complete view hierarchy teardown and recreation. The fix required auditing structural identity across four views, adding explicit .id(record.id) where needed, and migrating the view model from @ObservableObject with coarse-grained @Published properties to the @Observable macro’s fine-grained tracking, which re-renders only the specific views that read a changed property. List scrolling reached 60fps stable after the changes, verified with the SwiftUI Instruments template in Xcode.

The third month migrated the app from Core Data to SwiftData. The existing NSManagedObject subclasses had 14 entity types, two of which used NSPersistentCloudKitContainer for iCloud sync. The migration required designing a VersionedSchema hierarchy, implementing MigrationStage.custom closures for three entities whose attribute types changed, and verifying CloudKit schema compatibility by running the migration against a test container in the CloudKit Dashboard. The client’s App Store review was approved on the first submission.

Swift developers, Swift architects, and iOS consultants on monthly retainer — fractional Swift engineers, SwiftUI consultants, and Swift concurrency advisors — do their highest-value work in the actor isolation design, SwiftUI view lifecycle optimization, SwiftData schema migration, and testing infrastructure that produces the reliable, performant iOS app the engineering director defends to the product organization. This guide covers Swift concurrency in depth, the Swift type system, SwiftUI architecture, SwiftData, the Swift Testing framework, and how to structure a Swift developer retainer that makes the hours behind each optimization visible.

Swift concurrency

Swift concurrency — async/await, actors, Sendable, and AsyncStream — replaced the DispatchQueue and callback-handler model introduced in Objective-C. The Swift compiler enforces actor isolation at compile time when strict concurrency checking is enabled (-strict-concurrency=complete), making data races a compile error rather than a runtime crash. A Swift architect on retainer typically spends a full month on the initial concurrency migration for a mature codebase: enabling the strict concurrency compiler flag, resolving the cascade of Sendable violations, and redesigning shared mutable state as actors.

async/await and structured concurrency

async/await is the surface syntax for asynchronous calls. Structured concurrency — async let, withTaskGroup, and withThrowingTaskGroup — is the mechanism for parallel execution within a single task tree. async let launches a child task immediately and suspends at the await point only when the value is needed, enabling parallel execution of independent work without manual task management. withTaskGroup(of:body:) handles dynamic fan-out where the number of parallel operations is not known at compile time.

// async let — parallel execution of independent async calls:
func loadDashboard(userId: String) async throws -> Dashboard {
    // Both calls start immediately in parallel child tasks:
    async let profile = fetchProfile(userId: userId)
    async let recentWorkouts = fetchRecentWorkouts(userId: userId, limit: 10)
    async let stats = fetchStats(userId: userId, period: .lastThirtyDays)

    // Suspension happens here — waits for all three to complete:
    return try await Dashboard(
        profile: profile,
        workouts: recentWorkouts,
        stats: stats
    )
}

// withTaskGroup — dynamic fan-out with a collection of async operations:
func prefetchThumbnails(for workoutIds: [String]) async throws -> [String: UIImage] {
    try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
        for id in workoutIds {
            group.addTask {
                let image = try await self.thumbnailLoader.load(workoutId: id)
                return (id, image)
            }
        }

        var results: [String: UIImage] = [:]
        for try await (id, image) in group {
            results[id] = image
        }
        return results
    }
}

// Task cancellation — propagates automatically through task tree:
func fetchWithTimeout<T>(
    timeout: Duration,
    operation: @escaping @Sendable () async throws -> T
) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask { try await operation() }
        group.addTask {
            try await Task.sleep(for: timeout)
            throw CancellationError() // cancels sibling tasks automatically
        }
        let result = try await group.next()!
        group.cancelAll()
        return result
    }
}

Actor isolation

An actor is a reference type whose stored properties are isolated to a single concurrent execution context. The Swift compiler prevents any external code from accessing actor-isolated properties without await, making the isolation compile-time enforced rather than convention-based. @MainActor is a global actor that isolates code to the main thread; it replaces DispatchQueue.main.async for UI updates. The nonisolated keyword marks methods and computed properties that do not access isolated state, allowing them to be called without await.

// actor — serializes access to shared mutable state:
actor DataSyncManager {
    private var pendingUploads: [UploadTask] = []
    private var isSyncing = false

    func enqueue(_ task: UploadTask) {
        // No await needed inside the actor — already on the actor's executor
        pendingUploads.append(task)
    }

    func startSync() async throws {
        guard !isSyncing else { return }
        isSyncing = true
        defer { isSyncing = false }

        // await here creates a suspension point — actor is REENTRANT:
        // Another caller may enter enqueue() while this suspension happens.
        // Design state to be safe under reentrancy.
        let batch = pendingUploads
        pendingUploads = []

        try await uploadBatch(batch) // actor is released during this await
    }

    nonisolated var description: String {
        // nonisolated — accessible without await, cannot access isolated state
        "DataSyncManager"
    }
}

// Calling actor methods from non-isolated context requires await:
let manager = DataSyncManager()
await manager.enqueue(task)
try await manager.startSync()

// @MainActor — guarantees UI updates on the main thread:
@MainActor
class WorkoutListViewModel: ObservableObject {
    @Published var workouts: [Workout] = []
    @Published var isLoading = false

    func load() async {
        isLoading = true
        do {
            // Leaves @MainActor isolation during the network call:
            let fetched = try await workoutService.fetchAll()
            // Returns to @MainActor before assigning (triggers @Published):
            workouts = fetched
        } catch {
            // Error handling on main thread:
            print("Load failed: \(error)")
        }
        isLoading = false
    }
}

// isolated parameter — accept an actor instance and run on its executor:
func auditPendingUploads(on manager: isolated DataSyncManager) -> Int {
    // Runs on manager's actor executor — can access isolated state synchronously:
    return manager.pendingUploads.count
}

Sendable protocol and @Sendable closures

Sendable (SE-0302) is a marker protocol that the Swift compiler uses to verify that a value can safely be transferred across concurrency boundaries without data races. Value types that contain only Sendable properties are Sendable by default. Classes require explicit conformance — either by making all properties immutable and non-inherited, or by using @unchecked Sendable to assert manual synchronization. @Sendable closures are closures that can be passed to Task { } and actor methods: they cannot capture mutable variables from non-isolated contexts.

// Struct with Sendable members — implicitly Sendable:
struct WorkoutRecord: Sendable {
    let id: String           // String is Sendable
    let duration: TimeInterval // Double is Sendable
    let timestamp: Date      // Date is Sendable (value type)
}

// Final class with immutable properties — explicitly Sendable:
final class UserSession: Sendable {
    let userId: String
    let authToken: String
    init(userId: String, authToken: String) {
        self.userId = userId
        self.authToken = authToken
    }
}

// @unchecked Sendable — manual synchronization, suppress compiler check:
// Use only when you can guarantee thread safety (e.g., using os_unfair_lock):
final class ThreadSafeCache<Key: Hashable, Value>: @unchecked Sendable {
    private var storage: [Key: Value] = [:]
    private let lock = NSLock()

    func get(_ key: Key) -> Value? {
        lock.withLock { storage[key] }
    }

    func set(_ key: Key, value: Value) {
        lock.withLock { storage[key] = value }
    }
}

// @Sendable closure — safe to pass to Task or actor methods:
func scheduleBackgroundWork(
    completion: @Sendable @escaping () async -> Void
) {
    Task {
        await completion() // @Sendable closure is safe to call from Task
    }
}

// Enabling strict concurrency in Package.swift (Swift 5.10+):
// .target(
//     name: "MyApp",
//     swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
// )
// Or in Xcode: Swift Compiler - Upcoming Features > Strict Concurrency Checking = Complete

AsyncStream and AsyncThrowingStream

AsyncStream bridges delegate-based or callback-based APIs into Swift’s async sequence model. In Swift 5.9+, AsyncStream.makeStream(of:bufferingPolicy:) returns a (stream, continuation) tuple, which is the preferred over the closure-based initializer for cases where the continuation needs to be stored. continuation.yield(_:) produces elements; continuation.finish() or continuation.finish(throwing:) terminates the sequence.

// AsyncStream.makeStream — bridge CLLocationManager delegate callbacks:
class LocationService: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    private var continuation: AsyncStream<CLLocation>.Continuation?

    var locationStream: AsyncStream<CLLocation> {
        let (stream, continuation) = AsyncStream.makeStream(
            of: CLLocation.self,
            bufferingPolicy: .bufferingNewest(10) // drop oldest if consumer is slow
        )
        self.continuation = continuation
        manager.delegate = self
        manager.startUpdatingLocation()

        // Register cleanup when the stream is terminated:
        continuation.onTermination = { [weak self] _ in
            self?.manager.stopUpdatingLocation()
        }
        return stream
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else { return }
        continuation?.yield(location)
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        // AsyncStream swallows errors — use AsyncThrowingStream if error propagation is needed
        continuation?.finish()
    }
}

// Consuming the stream in a SwiftUI view model:
@Observable
class MapViewModel {
    var currentLocation: CLLocation?

    func startTracking(locationService: LocationService) async {
        for await location in locationService.locationStream {
            // Runs on whatever actor this method is isolated to:
            currentLocation = location
        }
    }
}

// AsyncThrowingStream — for fallible sequences:
func watchDatabaseChanges() -> AsyncThrowingStream<[Record], Error> {
    AsyncThrowingStream { continuation in
        let observer = DatabaseObserver { result in
            switch result {
            case .success(let records):
                continuation.yield(records)
            case .failure(let error):
                continuation.finish(throwing: error)
            }
        }
        continuation.onTermination = { _ in observer.invalidate() }
    }
}

// withCheckedContinuation — bridge one-shot completion handlers:
func fetchUserProfile(userId: String) async throws -> UserProfile {
    try await withCheckedThrowingContinuation { continuation in
        legacyAPIClient.fetchProfile(userId: userId) { result in
            // Called exactly once — continuation must be resumed exactly once:
            switch result {
            case .success(let profile): continuation.resume(returning: profile)
            case .failure(let error):  continuation.resume(throwing: error)
            }
        }
    }
}

Swift type system

Swift’s type system — generics, associated types, opaque types, existentials, result builders, property wrappers, and the macro system — is where a Swift architect on retainer makes decisions that determine the long-term maintainability of the codebase. The choices between some and any, between a protocol with associated types and a generic constraint, between a property wrapper and a computed property, are not visible in the feature output but determine whether the codebase can be extended cleanly over years.

Generics, where clauses, and primary associated types

// Generic function with where clause — constrain multiple type parameters:
func merge<S: Sequence, T: Sequence>(
    _ first: S,
    _ second: T
) -> [S.Element] where S.Element == T.Element, S.Element: Comparable {
    (first + second).sorted()
}

// Protocol with associated type — Repository pattern:
protocol Repository {
    associatedtype Entity: Identifiable
    associatedtype ID = Entity.ID

    func findById(_ id: ID) async throws -> Entity?
    func findAll() async throws -> [Entity]
    func save(_ entity: Entity) async throws
    func delete(_ id: ID) async throws
}

// Primary associated types (SE-0346, Swift 5.7+):
// Declare primary associated types in the protocol definition:
protocol DataStore<StoredValue> {
    associatedtype StoredValue: Codable & Identifiable
    func fetch(id: StoredValue.ID) async throws -> StoredValue
    func store(_ value: StoredValue) async throws
}

// Use with 'some' shorthand syntax — like generic constraints:
func createCache<S: DataStore<Workout>>(backing store: S) -> some DataStore<Workout> {
    CachingStore(backing: store)
}

// Constrained existential (Swift 5.7+):
func printAll(from store: any DataStore<Workout>) async throws {
    // 'any DataStore<Workout>' — existential with primary associated type constraint
    let workout = try await store.fetch(id: "abc")
    print(workout)
}

Opaque types some vs. existentials any

The some keyword returns an opaque type: the caller does not know the concrete type, but the compiler does, enabling static dispatch and protocol-associated-type constraints. The any keyword (required in Swift 5.7+ for existential types) creates an existential container that holds any type conforming to the protocol, using dynamic dispatch. Existentials with any cannot satisfy associated type requirements or Equatable constraints without type erasure. The any keyword’s mandatory use (SE-0309) makes the dynamic-dispatch cost explicit at the call site.

protocol Renderer {
    associatedtype Output
    func render(view: some View) -> Output
}

// 'some Renderer' — opaque return, static dispatch, compiler knows concrete type:
func makeDefaultRenderer() -> some Renderer {
    MetalRenderer() // Concrete type hidden from callers but known to compiler
}

// 'any Renderer' — existential, dynamic dispatch, concrete type erased at runtime:
// CANNOT use when Renderer has associated type without additional type erasure
// For protocols without associated types, existential works directly:
protocol Analytics {
    func track(event: String, properties: [String: Any])
}

// Heterogeneous collection of Analytics implementations:
let analyticsProviders: [any Analytics] = [
    FirebaseAnalytics(),
    MixpanelAnalytics(),
    AmplitudeAnalytics()
]

for provider in analyticsProviders {
    provider.track(event: "app_launch", properties: [:]) // dynamic dispatch
}

// Prefer 'some' for function parameters (opaque parameter type, Swift 5.7+):
func display(_ view: some View) { } // equivalent to <V: View>(_ view: V)
// vs.
func display(_ view: any View) { } // existential — slightly more overhead

// Type erasure when existential <-> associated type is required:
struct AnyRepository<Entity: Identifiable>: Repository {
    private let _findById: (Entity.ID) async throws -> Entity?
    private let _findAll: () async throws -> [Entity]
    private let _save: (Entity) async throws -> Void
    private let _delete: (Entity.ID) async throws -> Void

    init<R: Repository>(_ repo: R) where R.Entity == Entity {
        _findById = { try await repo.findById($0) }
        _findAll  = { try await repo.findAll() }
        _save     = { try await repo.save($0) }
        _delete   = { try await repo.delete($0) }
    }

    func findById(_ id: Entity.ID) async throws -> Entity? { try await _findById(id) }
    func findAll()                 async throws -> [Entity] { try await _findAll() }
    func save(_ entity: Entity)    async throws             { try await _save(entity) }
    func delete(_ id: Entity.ID)   async throws             { try await _delete(id) }
}

Result builders and @ViewBuilder

// @resultBuilder — custom DSL for declarative construction:
@resultBuilder
struct RouteBuilder {
    static func buildBlock(_ routes: Route...) -> [Route] {
        Array(routes)
    }

    static func buildOptional(_ routes: [Route]?) -> [Route] {
        routes ?? []
    }

    static func buildEither(first routes: [Route]) -> [Route] { routes }
    static func buildEither(second routes: [Route]) -> [Route] { routes }

    static func buildArray(_ routeArrays: [[Route]]) -> [Route] {
        routeArrays.flatMap { $0 }
    }
}

func buildRouter(@RouteBuilder _ content: () -> [Route]) -> Router {
    Router(routes: content())
}

// Usage — DSL syntax enabled by @resultBuilder:
let router = buildRouter {
    Route(path: "/profile", handler: profileHandler)
    Route(path: "/settings", handler: settingsHandler)

    if featureFlags.workoutsEnabled {
        Route(path: "/workouts", handler: workoutsHandler)
        Route(path: "/workouts/:id", handler: workoutDetailHandler)
    }
}

// @ViewBuilder — SwiftUI's result builder, same mechanism:
// SwiftUI uses buildBlock, buildOptional (for if), buildEither (for if/else)
// Custom @ViewBuilder function:
@ViewBuilder
func makeHeaderView(for user: User, isAdmin: Bool) -> some View {
    ProfileAvatarView(user: user)
    Text(user.displayName).font(.headline)
    if isAdmin {
        Label("Admin", systemImage: "shield.fill")
            .foregroundColor(.orange)
    }
}

Property wrappers and Swift macros

// @propertyWrapper — custom storage behavior with wrappedValue and projectedValue:
@propertyWrapper
struct Clamped<Value: Comparable> {
    private var value: Value
    let range: ClosedRange<Value>

    var wrappedValue: Value {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }

    // projectedValue accessed with $ prefix — can expose a Binding or publisher:
    var projectedValue: ClosedRange<Value> { range }

    init(wrappedValue: Value, _ range: ClosedRange<Value>) {
        self.range = range
        self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }
}

struct HeartRateMonitor {
    @Clamped(40...220) var bpm: Int = 70

    func adjust(to newBpm: Int) {
        bpm = newBpm      // clamped to 40-220 automatically
        print($bpm)       // projected value: 40...220
    }
}

// Swift macros (Swift 5.9+):
// @freestanding(expression) — invoked with # prefix at expression position:
// #URL("https://api.example.com") — compile-time URL validation
// Implementation lives in a separate macro target (SwiftSyntax-based):

// @attached(member) — adds members to the decorated type:
// @Observable is an attached macro that synthesizes:
//   - _$observationRegistrar property
//   - access() and withMutation() observation tracking calls
//   - _$id for identity
// Expanding @Observable on a class shows the generated code:

// Before macro expansion:
@Observable
class WorkoutSession {
    var heartRate: Int = 0
    var duration: TimeInterval = 0
    var isActive: Bool = false
}

// After @Observable macro expansion (compiler-generated, approximately):
// class WorkoutSession {
//     var heartRate: Int = 0 {
//         get { _$observationRegistrar.access(self, keyPath: \.heartRate); return _heartRate }
//         set { _$observationRegistrar.withMutation(self, keyPath: \.heartRate) { _heartRate = newValue } }
//     }
//     private var _heartRate: Int = 0
//     // ... similarly for duration and isActive ...
//     let _$observationRegistrar = Observation.ObservationRegistrar()
// }

SwiftUI architecture

SwiftUI’s declarative model makes it easy to build UIs that look correct but perform poorly. View identity, state ownership, environment propagation, and navigation architecture are the structural decisions that a Swift architect on retainer audits and redesigns. These decisions are invisible in the visual output but determine whether the app runs at 60fps on a three-year-old device.

View identity and lifetime

SwiftUI derives view identity from two sources. Structural identity: the position of a view in the view tree relative to its parent determines whether it is the same view across re-renders. Explicit identity: the .id(_:) modifier assigns an explicit identifier; changing the identifier destroys the existing view and creates a new one, resetting all @State. Misusing .id() — for example, calling .id(UUID()) in a computed property — recreates the view on every render pass. ForEach with a stable Identifiable id uses explicit identity for each element, enabling SwiftUI’s diffing to animate insertions and deletions correctly.

// WRONG: ForEach with index-based id — identity unstable on reorder or insert:
ForEach(workouts.indices, id: \.self) { index in
    WorkoutRow(workout: workouts[index])
}
// When a workout is inserted at index 0, every view beyond index 0
// receives a new index identity — SwiftUI tears down and recreates all of them.

// CORRECT: ForEach with Identifiable — stable identity enables diffing:
ForEach(workouts) { workout in   // workout.id used as stable key
    WorkoutRow(workout: workout)
}
// Insert at index 0: only the new element is created; others retain their state.

// Explicit .id() for intentional view recreation:
@State private var selectedFilter: FilterOption = .all

ScrollView {
    LazyVStack {
        ForEach(filteredWorkouts) { workout in
            WorkoutRow(workout: workout)
        }
    }
}
.id(selectedFilter) // Changing filter destroys scroll position — intentional UX choice

// Debugging view updates — add to any view body during development:
let _ = Self._printChanges()
// Prints: "WorkoutListView: _workouts changed." to the console
// Identifies which state change triggered a re-render

@Observable macro vs. @ObservableObject

The @Observable macro (iOS 17+, Swift 5.9+) replaces @ObservableObject with a more granular observation system. @ObservableObject invalidates the entire observing view when any @Published property changes — even properties the view does not read. @Observable tracks which specific properties a view reads during its last render pass and invalidates only when those properties change. This produces significantly fewer view re-renders in complex view hierarchies with multiple observed properties.

// @ObservableObject — coarse invalidation on any @Published change:
class DashboardViewModel: ObservableObject {
    @Published var workouts: [Workout] = []
    @Published var heartRate: Int = 0
    @Published var isLoading: Bool = false
    // Any @Published change invalidates all views observing this object,
    // even if the view only reads `isLoading`.
}

// Usage with @StateObject and @ObservedObject:
struct DashboardView: View {
    @StateObject private var viewModel = DashboardViewModel()
    var body: some View {
        LoadingView(isLoading: viewModel.isLoading) // re-renders on heartRate change too
    }
}

// @Observable — fine-grained property tracking (iOS 17+):
@Observable
class DashboardViewModel {
    var workouts: [Workout] = []   // no @Published needed
    var heartRate: Int = 0
    var isLoading: Bool = false
    // Observation framework tracks which property each view reads.
    // LoadingView that reads only `isLoading` will NOT re-render when heartRate changes.
}

// Usage with @State (replaces @StateObject for Observable types):
struct DashboardView: View {
    @State private var viewModel = DashboardViewModel()
    var body: some View {
        LoadingView(isLoading: viewModel.isLoading)
        // Re-renders ONLY when isLoading changes — heartRate changes ignored.
    }
}

// Passing Observable into child views — no @ObservedObject wrapper needed:
struct WorkoutListView: View {
    var viewModel: DashboardViewModel  // plain property — observation tracked automatically
    var body: some View {
        ForEach(viewModel.workouts) { workout in
            WorkoutRow(workout: workout)
        }
    }
}

// Backward compatibility for iOS 16 targets:
#if canImport(Observation)
@Observable class MyModel { var value = 0 }
#else
class MyModel: ObservableObject { @Published var value = 0 }
#endif

// Environment injection — Observable model:
// Environment(_:) instead of environmentObject(_:):
ContentView()
    .environment(dashboardViewModel) // Observable
// In child view:
@Environment(DashboardViewModel.self) var viewModel

@Environment, @EnvironmentObject, and PreferenceKey

// Custom EnvironmentKey — dependency injection through view tree:
private struct AnalyticsClientKey: EnvironmentKey {
    static let defaultValue: AnalyticsClient = .noop // non-optional default
}

extension EnvironmentValues {
    var analyticsClient: AnalyticsClient {
        get { self[AnalyticsClientKey.self] }
        set { self[AnalyticsClientKey.self] = newValue }
    }
}

// Inject at root:
ContentView()
    .environment(\.analyticsClient, FirebaseAnalyticsClient())

// Consume anywhere in view tree without prop-drilling:
struct WorkoutDetailView: View {
    @Environment(\.analyticsClient) private var analytics
    @Environment(\.dismiss) private var dismiss // built-in environment values

    var body: some View {
        Button("Start Workout") {
            analytics.track("workout_started")
            // ...
        }
    }
}

// PreferenceKey — communicate data upward from child to parent:
struct MaxHeightKey: PreferenceKey {
    static let defaultValue: CGFloat = 0

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

// Child reports its height via preference:
struct ThumbnailView: View {
    var body: some View {
        Image(systemName: "figure.run")
            .background(
                GeometryReader { geo in
                    Color.clear
                        .preference(key: MaxHeightKey.self, value: geo.size.height)
                }
            )
    }
}

// Parent collects maximum child height:
struct ThumbnailGrid: View {
    @State private var maxChildHeight: CGFloat = 0

    var body: some View {
        HStack {
            ForEach(thumbnails) { thumbnail in
                ThumbnailView()
                    .frame(height: maxChildHeight) // uniform height
            }
        }
        .onPreferenceChange(MaxHeightKey.self) { height in
            maxChildHeight = height
        }
    }
}

NavigationStack and path-based routing

NavigationStack (iOS 16+) replaces NavigationView with a path-based navigation model. The navigation stack’s state is represented as a NavigationPath (type-erased heterogeneous path) or a typed array of a single Hashable type. navigationDestination(for:destination:) registers view factories for specific value types, enabling deep-linking by programmatically populating the path array from URL handlers or push notification payloads.

// Typed navigation path — homogeneous stack, all items same Hashable type:
enum AppRoute: Hashable {
    case workoutList
    case workoutDetail(workoutId: String)
    case heartRateChart(workoutId: String, range: ChartRange)
    case settings
    case editProfile
}

@Observable
class AppNavigationModel {
    var path: [AppRoute] = []

    func navigateTo(_ route: AppRoute) {
        path.append(route)
    }

    func popToRoot() {
        path = []
    }

    // Deep link handler — e.g. myapp://workouts/abc123/chart
    func handleDeepLink(_ url: URL) {
        guard url.scheme == "myapp",
              url.pathComponents.count >= 2,
              url.pathComponents[1] == "workouts"
        else { return }

        let workoutId = url.pathComponents[2]
        path = [.workoutList, .workoutDetail(workoutId: workoutId)]

        if url.pathComponents.last == "chart" {
            path.append(.heartRateChart(workoutId: workoutId, range: .lastHour))
        }
    }
}

struct AppView: View {
    @State private var navigation = AppNavigationModel()

    var body: some View {
        NavigationStack(path: $navigation.path) {
            WorkoutListView()
                .navigationDestination(for: AppRoute.self) { route in
                    switch route {
                    case .workoutList:
                        WorkoutListView()
                    case .workoutDetail(let id):
                        WorkoutDetailView(workoutId: id)
                    case .heartRateChart(let id, let range):
                        HeartRateChartView(workoutId: id, range: range)
                    case .settings:
                        SettingsView()
                    case .editProfile:
                        EditProfileView()
                    }
                }
        }
        .environment(navigation)
        .onOpenURL { url in navigation.handleDeepLink(url) }
    }
}

// NavigationSplitView — three-column layout for iPad and macOS:
struct SplitAppView: View {
    @State private var selectedCategory: WorkoutCategory?
    @State private var selectedWorkout: Workout?

    var body: some View {
        NavigationSplitView {
            // Sidebar — workout categories:
            List(WorkoutCategory.allCases, selection: $selectedCategory) { category in
                Label(category.name, systemImage: category.icon)
            }
        } content: {
            // Content — workout list for selected category:
            if let category = selectedCategory {
                WorkoutListView(category: category, selection: $selectedWorkout)
            } else {
                ContentUnavailableView("Select a Category", systemImage: "figure.run")
            }
        } detail: {
            // Detail — workout detail for selected workout:
            if let workout = selectedWorkout {
                WorkoutDetailView(workoutId: workout.id)
            } else {
                ContentUnavailableView("Select a Workout", systemImage: "heart.fill")
            }
        }
        .navigationSplitViewStyle(.balanced)
    }
}

SwiftData

SwiftData (iOS 17+, Xcode 15+) replaces Core Data with a Swift-native persistence framework that uses macros for schema definition and eliminates the .xcdatamodeld file. The @Model macro synthesizes NSManagedObject-equivalent persistence machinery from ordinary Swift classes. A Swift architect on retainer designs the ModelContainer configuration, the @Model class hierarchy, and the VersionedSchema migration plan that will keep user data intact through schema evolution.

ModelContainer setup and configuration

// ModelContainer — the root of the SwiftData stack:
import SwiftData

// Simple in-memory container for previews and tests:
let previewContainer = try! ModelContainer(
    for: Workout.self, HeartRateSample.self, UserProfile.self,
    configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)

// Production container with iCloud sync:
let productionContainer: ModelContainer = {
    let schema = Schema([
        Workout.self,
        HeartRateSample.self,
        UserProfile.self
    ])

    let cloudConfig = ModelConfiguration(
        schema: schema,
        url: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
            .appending(path: "FitnessApp.store"),
        cloudKitContainerIdentifier: "iCloud.com.example.FitnessApp"
    )

    return try! ModelContainer(for: schema, configurations: cloudConfig)
}()

// Inject into SwiftUI app:
@main
struct FitnessApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(productionContainer)
    }
}

@Model macro, attributes, and relationships

// @Model — Swift class becomes a SwiftData persistent model:
@Model
final class Workout {
    // @Attribute(.unique) — enforces unique constraint in the store:
    @Attribute(.unique) var id: String

    var title: String
    var date: Date
    var durationSeconds: Int
    var caloriesBurned: Double

    // @Attribute(.externalStorage) — large binary data stored outside the database file:
    @Attribute(.externalStorage) var gpxData: Data?

    // @Relationship — one-to-many: Workout has many HeartRateSamples
    // deleteRule: .cascade — deleting Workout deletes all its HeartRateSamples:
    @Relationship(deleteRule: .cascade, inverse: \HeartRateSample.workout)
    var heartRateSamples: [HeartRateSample] = []

    // @Relationship — many-to-one: Workout belongs to one UserProfile
    // deleteRule: .nullify — deleting the UserProfile sets workout.owner to nil:
    @Relationship(deleteRule: .nullify)
    var owner: UserProfile?

    init(id: String = UUID().uuidString, title: String, date: Date, durationSeconds: Int) {
        self.id = id
        self.title = title
        self.date = date
        self.durationSeconds = durationSeconds
        self.caloriesBurned = 0
    }
}

@Model
final class HeartRateSample {
    var timestamp: Date
    var bpm: Int
    var workout: Workout?   // inverse relationship declared here

    init(timestamp: Date, bpm: Int) {
        self.timestamp = timestamp
        self.bpm = bpm
    }
}

@Model
final class UserProfile {
    @Attribute(.unique) var userId: String
    var displayName: String
    var email: String

    // @Relationship(deleteRule: .deny) — prevents deletion if workouts exist:
    @Relationship(deleteRule: .deny, inverse: \Workout.owner)
    var workouts: [Workout] = []

    init(userId: String, displayName: String, email: String) {
        self.userId = userId
        self.displayName = displayName
        self.email = email
    }
}

@Query macro and #Predicate in SwiftUI views

// @Query — fetches and observes a SwiftData collection in a SwiftUI view:
struct WorkoutListView: View {
    // Sort by date descending, no filter:
    @Query(sort: \Workout.date, order: .reverse)
    private var allWorkouts: [Workout]

    var body: some View {
        List(allWorkouts) { workout in
            WorkoutRow(workout: workout)
        }
    }
}

// @Query with filter — #Predicate macro for type-safe predicates:
struct RecentWorkoutsView: View {
    let minimumDuration: Int // in seconds

    // Dynamic filter from stored property — pass filter to @Query via init:
    @Query private var workouts: [Workout]

    init(minimumDuration: Int, afterDate: Date) {
        self.minimumDuration = minimumDuration
        // #Predicate — type-safe, compile-time checked predicate expression:
        let predicate = #Predicate<Workout> { workout in
            workout.durationSeconds >= minimumDuration &&
            workout.date > afterDate
        }
        let descriptor = FetchDescriptor<Workout>(
            predicate: predicate,
            sortBy: [SortDescriptor(\.date, order: .reverse)]
        )
        _workouts = Query(descriptor)
    }

    var body: some View {
        ForEach(workouts) { workout in
            WorkoutRow(workout: workout)
        }
    }
}

// Programmatic fetch with ModelContext — outside of SwiftUI views:
func fetchLongWorkouts(context: ModelContext) throws -> [Workout] {
    let predicate = #Predicate<Workout> { $0.durationSeconds > 3600 }
    var descriptor = FetchDescriptor<Workout>(
        predicate: predicate,
        sortBy: [SortDescriptor(\.caloriesBurned, order: .reverse)]
    )
    descriptor.fetchLimit = 50
    return try context.fetch(descriptor)
}

// ModelContext CRUD — accessed via @Environment(\.modelContext) in views:
struct AddWorkoutView: View {
    @Environment(\.modelContext) private var context
    @Environment(\.dismiss) private var dismiss

    func save() {
        let workout = Workout(
            title: "Morning Run",
            date: .now,
            durationSeconds: 1800
        )
        context.insert(workout)   // insert into context
        // SwiftData auto-saves on scene phase changes; explicit save also available:
        try? context.save()
        dismiss()
    }
}

SwiftData migrations with VersionedSchema

// VersionedSchema — snapshot the schema at each version:
enum AppSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [WorkoutV1.self] }

    @Model final class WorkoutV1 {
        var title: String
        var date: Date
        var durationSeconds: Int
        init(title: String, date: Date, durationSeconds: Int) {
            self.title = title; self.date = date; self.durationSeconds = durationSeconds
        }
    }
}

enum AppSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    // V2 adds caloriesBurned and splits title into name + category
    static var models: [any PersistentModel.Type] { [WorkoutV2.self] }

    @Model final class WorkoutV2 {
        var name: String         // renamed from title
        var category: String     // new field
        var date: Date
        var durationSeconds: Int
        var caloriesBurned: Double // new field, default 0
        init(name: String, category: String, date: Date, durationSeconds: Int) {
            self.name = name; self.category = category; self.date = date
            self.durationSeconds = durationSeconds; self.caloriesBurned = 0
        }
    }
}

// SchemaMigrationPlan — declares migration stages between versions:
enum AppMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [AppSchemaV1.self, AppSchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    // MigrationStage.custom — run code before and after lightweight migration:
    static let migrateV1toV2 = MigrationStage.custom(
        fromVersion: AppSchemaV1.self,
        toVersion: AppSchemaV2.self,
        willMigrate: { context in
            // Called before migration — access old schema objects:
            let oldWorkouts = try context.fetch(FetchDescriptor<AppSchemaV1.WorkoutV1>())
            for workout in oldWorkouts {
                // Store title in a temp attribute before migration wipes it:
                // (In practice, use a migration-specific model or UserDefaults for temp storage)
                UserDefaults.standard.set(workout.title, forKey: "migrate_\(workout.persistentModelID)")
            }
        },
        didMigrate: { context in
            // Called after migration — access new schema objects:
            let newWorkouts = try context.fetch(FetchDescriptor<AppSchemaV2.WorkoutV2>())
            for workout in newWorkouts {
                let savedTitle = UserDefaults.standard.string(
                    forKey: "migrate_\(workout.persistentModelID)"
                ) ?? workout.name
                // Parse category from old title convention ("Run: Central Park" -> category "Run"):
                let parts = savedTitle.split(separator: ":", maxSplits: 1)
                workout.category = parts.count == 2 ? String(parts[0]).trimmingCharacters(in: .whitespaces) : "General"
                workout.name = parts.count == 2 ? String(parts[1]).trimmingCharacters(in: .whitespaces) : savedTitle
            }
            try context.save()
        }
    )
}

// Use migration plan in ModelContainer:
let migratingContainer = try ModelContainer(
    for: AppSchemaV2.WorkoutV2.self,
    migrationPlan: AppMigrationPlan.self,
    configurations: ModelConfiguration(url: storeURL)
)

Swift Testing framework

The Swift Testing framework (Xcode 16, Swift 5.10+) replaces XCTest’s convention-based test discovery (func testXxx()) with macro-based declaration (@Test) and adds parameterized tests, suite-level configuration, and first-class async/await support. A Swift architect on retainer builds the testing infrastructure and testing conventions that the development team follows: parameterized tests for model validation, async tests for actor-isolated code, and suite-level serial execution for tests that share state.

@Test, @Suite, and parameterized tests

import Testing

// @Suite — groups related tests; @Suite(.serialized) runs tests serially:
@Suite("WorkoutValidator Tests")
struct WorkoutValidatorTests {

    // @Test — replaces func testXxx() convention:
    @Test("Valid workout passes validation")
    func validWorkoutPassesValidation() throws {
        let workout = Workout(title: "Morning Run", date: .now, durationSeconds: 1800)
        let validator = WorkoutValidator()

        let result = try validator.validate(workout)

        #expect(result.isValid)
        #expect(result.errors.isEmpty)
    }

    // Parameterized @Test — runs once for each argument tuple:
    @Test(
        "Invalid duration is rejected",
        arguments: [
            (duration: -1,    expectedError: "Duration must be positive"),
            (duration: 0,     expectedError: "Duration must be positive"),
            (duration: 86401, expectedError: "Duration cannot exceed 24 hours"),
        ]
    )
    func invalidDurationIsRejected(duration: Int, expectedError: String) throws {
        let workout = Workout(title: "Test", date: .now, durationSeconds: duration)
        let validator = WorkoutValidator()

        let result = try validator.validate(workout)

        #expect(!result.isValid)
        #expect(result.errors.contains(expectedError))
    }

    // #require — like #expect but throws on failure (stops test immediately):
    @Test("Workout with nil title fails with specific error")
    func workoutWithEmptyTitleFails() throws {
        let workout = Workout(title: "", date: .now, durationSeconds: 1800)
        let validator = WorkoutValidator()

        let result = try validator.validate(workout)
        let firstError = try #require(result.errors.first) // throws if nil — stops test
        #expect(firstError == "Title cannot be empty")
    }
}

// Async test — await directly in test body:
@Suite("DataSyncManager Actor Tests", .serialized) // serialized: no parallel execution
struct DataSyncManagerTests {

    @Test("Enqueue and flush complete successfully")
    func enqueueAndFlushCompletesSuccessfully() async throws {
        let manager = DataSyncManager()
        let task1 = UploadTask(id: "t1", payload: Data("payload1".utf8))
        let task2 = UploadTask(id: "t2", payload: Data("payload2".utf8))

        await manager.enqueue(task1)
        await manager.enqueue(task2)

        // Verify actor state before flush:
        let count = await manager.pendingCount
        #expect(count == 2)

        // Test async actor method:
        try await manager.startSync()

        let remaining = await manager.pendingCount
        #expect(remaining == 0)
    }

    // Testing AsyncStream:
    @Test("LocationService emits locations from delegate")
    func locationServiceEmitsLocations() async throws {
        let service = MockLocationService()
        var emitted: [CLLocation] = []

        let streamTask = Task {
            for await location in service.locationStream {
                emitted.append(location)
                if emitted.count == 3 { break }
            }
        }

        // Drive the mock delegate:
        let coords = [(37.334, -122.009), (37.335, -122.010), (37.336, -122.011)]
        for (lat, lon) in coords {
            service.simulateLocation(CLLocation(latitude: lat, longitude: lon))
        }

        await streamTask.value

        #expect(emitted.count == 3)
        #expect(abs(emitted[0].coordinate.latitude - 37.334) < 0.001)
    }
}

// XCTest async — still relevant for UI tests and pre-Xcode 16 targets:
final class WorkoutAPITests: XCTestCase {
    func testFetchWorkoutsReturnsNonEmptyList() async throws {
        let service = WorkoutService(baseURL: URL(string: "https://api.test.example.com")!)
        let workouts = try await service.fetchAll(userId: "user-001")
        XCTAssertFalse(workouts.isEmpty, "Expected at least one workout")
    }

    // XCTestExpectation for callback-based APIs not yet migrated to async:
    func testLegacyUploadCallsCompletion() {
        let expectation = expectation(description: "upload completes")
        var receivedResult: Result<String, Error>?

        legacyUploader.upload(data: Data()) { result in
            receivedResult = result
            expectation.fulfill()
        }

        wait(for: [expectation], timeout: 5.0)
        XCTAssertNotNil(receivedResult)
    }
}

// @MainActor test — verifies UI-layer code on the main actor:
@Suite("WorkoutListViewModel Tests")
@MainActor
struct WorkoutListViewModelTests {

    @Test("Loading sets isLoading true then false")
    func loadingTogglesIsLoading() async throws {
        let viewModel = WorkoutListViewModel()
        #expect(!viewModel.isLoading)

        let loadTask = Task { await viewModel.load() }
        // Yield to let load() start and set isLoading = true:
        await Task.yield()
        #expect(viewModel.isLoading)

        await loadTask.value
        #expect(!viewModel.isLoading)
        #expect(!viewModel.workouts.isEmpty)
    }
}

Logging Swift retainer hours so clients understand the work

Swift retainer work is invisible in exactly the same way that all platform engineering work is invisible: a Swift concurrency migration that replaces a DispatchQueue singleton with an actor produces no new feature, no new screen, and no visible change in the product — only a crash rate that dropped from 12 per week to zero. An actor isolation audit that resolves 34 Sendable conformance violations identified by enabling -strict-concurrency=complete produces a codebase that is provably race-free under the Swift compiler’s concurrency model, but looks identical from the product manager’s perspective. A SwiftUI view identity debugging session that traces 38fps list scroll to index-based ForEach identity and fixes it to 60fps produces no new view, only a faster one. A SwiftData VersionedSchema migration plan that prevents data loss during a schema change produces no visible feature — it produces the absence of a one-star App Store review.

The work log entry is what connects the invisible Swift platform work to its concrete business outcome. A good Swift retainer entry captures: the advisory category (Swift concurrency migration, actor isolation audit, Sendable conformance review, SwiftUI view identity debugging, @Observable adoption, SwiftData schema migration, Swift Testing suite, Instruments profiling session, App Store submission advisory, code review), the specific module or feature being worked on, the task performed — including the TSan finding, the Instruments flame graph observation, or the SwiftUI _printChanges() output that identified the problem — and the implementation approach and measured outcome. An Instruments CPU profiling session that finds a JSONDecoder allocation on the main thread causing scroll jank deserves its own entry, with the thread callstack, the dispatch fix, and the post-fix frame time.

HourTab turns this structured work log into a public retainer URL that the client can bookmark — a live view of hours logged, progress against the monthly allocation, and the work summaries behind each line. When the client asks “what has our Swift architect been doing this month?”, the HourTab URL answers with the TSan finding, the actor isolation design that resolved it, and the crash rate reduction in Crashlytics, without requiring a status call or a written report.

Retainer structure for Swift developer engagements

A Swift developer retainer typically covers four functional areas: feature development (new SwiftUI views and view models, new SwiftData @Model classes and @Query views, new networking with async/await and URLSession), architecture advisory (actor isolation design, @Observable migration, NavigationStack routing design, SwiftData schema planning), performance optimization (Instruments profiling for CPU and memory; SwiftUI view rendering analysis with the SwiftUI template; MetricKit integration for production performance monitoring; App Store Organizer hang rate analysis), and testing infrastructure (Swift Testing @Suite and parameterized @Test setup, XCTest async migration, mock actor and AsyncStream test harnesses). Each area should have its own hour allocation in the retainer agreement to prevent feature development from crowding out the architecture advisory hours that deliver the highest long-term value.

Monthly retainer amounts for iOS architecture advisory and Swift consulting typically range from $8,000 to $18,000 per month. A mid-level Swift engineer billing at $145 to $255 per hour filling 20 to 25 hours per month covers feature development and ongoing SwiftUI review. A senior Swift architect billing at $205 to $380 per hour filling 20 to 40 hours per month covers concurrency migration, SwiftData schema governance, performance optimization, and App Store submission advisory. iOS consulting firms typically bill at $170 to $305 per hour for dedicated Swift retainer engagements.

The retainer pays for itself when it prevents a single App Store crisis: a SwiftData migration that was not designed with VersionedSchema and SchemaMigrationPlan will fail on user devices when the schema changes, wiping user data or causing a crash loop that blocks app launch. The one-star review cascade and emergency hotfix release cost more than six months of architecture advisory retainer. Monthly retainer advisory prevents that accumulation by ensuring the SwiftData migration plan is designed before the schema change is shipped, not after the crash reports arrive.


HourTab is a public retainer dashboard for freelance Swift developers and iOS consulting firms. Upload your time-tracker CSV and get a shareable URL your client can bookmark — a live view of hours logged, remaining allocation, and work log summaries. No client login, no portal. Try it free with one active retainer.