App Academy

Software Stuff

Swift 6 Migration for Multi-Module Apps

Swift 6 turns data-race checks from “nice to have” into “this won’t compile.” If your app is split across many internal frameworks, the smoothest path is incremental: surface Swift 6 diagnostics while staying on Swift 5, fix them module by module, then flip each target to Swift 6. This post shows the exact order of operations, the most common fixes (@MainActor, actor, Sendable, @Sendable, @preconcurrency).


A tiny story to set the scene

You’ve just returned from a break. Coffee in hand, you open the project: an app with many internal frameworks. You try a quick build, and you can already hear Swift 6 whispering: “I can keep you safe, but we need to talk about concurrency.”

There are two ways forward: flip everything and drown in errors, or make a small smart move and let momentum do the rest. This guide is the latter—the one you’ll actually follow when real life (and deadlines) return.


First move: make Swift 6 visible without breaking anything

Before changing language modes, let the compiler show you the truth while you’re still on Swift 5. Think of it like turning the lights on before cleaning.

  • Xcode targetSwift Language Version: Swift 5, Strict Concurrency Checking: Complete
  • SPM targets → enable upcoming features so you see Swift 6 checks as warnings.
// HERO: Flip one leaf framework first, protect downstream clients, and keep moving up.
// Package.swift (for that one framework you’re migrating now)
import PackageDescription

let package = Package(
    name: "NetworkingCore",
    platforms: [.iOS(.v15)],
    products: [.library(name: "NetworkingCore", targets: ["NetworkingCore"])],
    targets: [
        .target(
            name: "NetworkingCore",
            swiftSettings: [
                // Surface Swift 6 checks early (even before you set tools-version: 6.0)
                .enableUpcomingFeature("StrictConcurrency"),
            ]
        ),
        .testTarget(name: "NetworkingCoreTests", dependencies: ["NetworkingCore"])
    ]
)

Why not a “big bang”? Swift 6 makes strict concurrency the default. If you flip everything at once, you’ll face a wall of errors across all modules. The human-friendly pattern is: (1) turn on strict checks as warnings, (2) migrate leaf frameworks first (no dependents), (3) work upward, (4) app target last, and (5) use @preconcurrency temporarily to keep Swift‑5 clients compiling.


Meet the cast (in plain language)

Think of your code as a team working in different rooms.

  • @MainActor = “the UI room.” Anything that updates the UI belongs here.
  • actor = “a room with a door.” Only one person edits the shared whiteboard at a time (serialized access).
  • Sendable = “safe to pass between rooms.” Value types usually are; reference types need proof.
  • @Sendable = “the note you pass under the door (closure) is safe.”
  • @preconcurrency = “a temporary hallway pass.” It lets older code walk through while you renovate.

Short, sweet examples follow.

@MainActor: mark the things that touch UI

@MainActor
final class ProfileViewModel: ObservableObject {
    @Published private(set) var state: State = .idle

    func load() async {
        state = .loading
        state = await service.load()
    }
}

@MainActor
protocol ProfileRouting: AnyObject {
    func showSettings()
}

actor: protect shared mutable state

actor ImageCache {
    private var store: [URL: Data] = [:]
    func data(for url: URL) -> Data? { store[url] }
    func insert(_ data: Data, for url: URL) { store[url] = data }
}

Sendable: types that cross concurrency domains

// Value types often get Sendable implicitly
struct Article { var id: Int; var title: String }

// Safe reference type pattern: final + immutable
final class User: Sendable {
    let id: UUID
    let name: String
    init(id: UUID, name: String) { self.id = id; self.name = name }
}

// If you must mutate, either move to an actor… or lock carefully (last resort)
final class MutableUser: @unchecked Sendable {
    private var name = ""
    private let lock = NSLock()
    func updateName(_ new: String) {
        lock.lock(); defer { lock.unlock() }
        name = new
    }
}

// Generics/enums only conform if their stored/associated types are sendable
struct Box<T: Sendable>: Sendable { let value: T }

enum AuthState: Sendable {
    case loggedOut
    case loggedIn(name: String)
}

@Sendable: closures that may hop threads/tasks

actor Articles {
    func filter(_ isIncluded: @Sendable (Article) -> Bool) -> [Article] {
        []
    }
}

let articles = Articles()
let keyword = "swift" // value capture → OK
let results = await articles.filter { $0.title.contains(keyword) }

@preconcurrency: a staging tool, not a lifestyle

Use it to keep Swift‑5 clients compiling while a framework is already on Swift 6.

@preconcurrency import LegacyAnalytics

@preconcurrency
public protocol LegacyOutput: AnyObject {
    func didFinish()
}

Keep a ticket list and remove these as you migrate dependents.


A mini-migration you can copy

Let’s migrate a leaf framework named ImageKit.

Before (shared mutable dictionary):

final class ImageCache {
    static let shared = ImageCache()
    private var store: [URL: Data] = [:]
    func get(_ url: URL) -> Data? { store[url] }
    func put(_ data: Data, for url: URL) { store[url] = data }
}

Step 1 — see Swift 6 warnings in Swift 5 mode. Fix what you see: move shared state into an actor, mark closures @Sendable.

public actor ImageCache {
    public static let shared = ImageCache()
    private var store: [URL: Data] = [:]
    public func data(for url: URL) -> Data? { store[url] }
    public func insert(_ data: Data, for url: URL) { store[url] = data }
}

public func warmCache(_ urls: [URL], fetch: @Sendable (URL) async throws -> Data) async {
    await withTaskGroup(of: Void.self) { group in
        for url in urls {
            group.addTask {
                let data = try await fetch(url)
                await ImageCache.shared.insert(data, for: url)
            }
        }
    }
}

Step 2 — flip this framework to Swift 6. Clean up any newly elevated diagnostics.

Step 3 — if the app is still on Swift 5, add @preconcurrency import ImageKit on the app side temporarily. Ship the framework, then move to the next leaf.


Human-sized troubleshooting

  • “Global variable is not actor-isolated” → make it immutable, mark @MainActor for UI, or move it into an actor.
  • “Capture of non-sendable type in @Sendable closure” → make the type Sendable, capture a snapshot value, or run that work inside an actor.
  • “Main-actor method doesn’t satisfy protocol requirement” → mark the protocol @MainActor so everything lines up.
  • Third‑party/Obj‑C types → confine to @MainActor (UI), wrap in an actor, or use a final @unchecked Sendable façade only if you truly own the invariants.

PR checklists you can paste

Per‑module

  • Swift 5 + Strict Concurrency (Complete) → 0 warnings
  • Public API actor‑annotated (@MainActor, actor) where needed
  • Cross‑task closures are @Sendable
  • Types crossing tasks are Sendable
  • No new @unchecked Sendable (or justified & documented)
  • Flipped to Swift 6 (target or tools‑version)
  • Temporary @preconcurrency shims tracked for removal
  • Tests + TSAN green

Final app cutover

  • All internal frameworks on Swift 6
  • @preconcurrency minimized (tickets exist for leftovers)
  • No mutable shared state outside actors
  • Zero concurrency diagnostics
  • Ship

Deep‑dive: the full migration plan (everything you need in one place)

This section condenses the exact steps I use on multi‑module codebases. It complements the story and examples above.

0) Set the ground rules

  • Toolchain: Use Xcode 16+ (Swift 6 toolchain) so you can opt into Swift 6 checks and still compile most targets in Swift 5 mode.
  • What actually changes: In Swift 6 language mode, strict concurrency/data‑race checks are errors. In Swift 5 mode, you can enable the same checks as warnings to fix issues early.

How to turn on the checks without breaking builds

  • Xcode targets → Build Settings → Strict Concurrency Checking = Complete, keep Swift Language Version = Swift 5.
  • SPM targets → in Package.swift add:
.target(
  name: "MyModule",
  swiftSettings: [
    .enableUpcomingFeature("StrictConcurrency"),
    // Optional: roll out more flags gradually
    // .enableUpcomingFeature("ImplicitOpenExistentials"),
    // .enableUpcomingFeature("BareSlashRegexLiterals"),
    // .enableUpcomingFeature("DisableOutwardActorInference"),
  ]
)

1) Plan the migration like a dependency tree

  1. Inventory your internal frameworks and sketch a dependency graph.
  2. Order of attack: migrate leaf frameworks first (few/no dependents), then mid‑layers, app target last.
  3. For SPM, keep most packages on // swift-tools-version: 5.x while you migrate select leaves to 6.0. Setting tools‑version to 6.0 opts that package into Swift 6 language mode.

2) Fix warnings first (still Swift 5)

With strict checks visible, clean each module while it still compiles in Swift 5.

  • Resolve @Sendable closure captures, add Sendable where needed, and isolate UI with @MainActor.
  • Prefer immutable data. Where mutation is required and shared across tasks, prefer actor to synchronize access.
  • If diagnostics point at third‑party or Obj‑C types, decide whether to confine them to an actor (often @MainActor) or wrap them.

3) Establish isolation & API rules per framework

A. UI & presentation

  • Annotate UI‑touching types/APIs with @MainActor (views, view models, routers, delegates called by UIKit/SwiftUI).

B. Shared mutable state

  • Eliminate global mutable state or wrap it in an actor:
actor ImageCache {
  private var store: [URL: Data] = [:]
  func data(for url: URL) -> Data? { store[url] }
  func insert(_ data: Data, for url: URL) { store[url] = data }
}

  • Globals that must remain can be @MainActor, or (last resort) nonisolated(unsafe) with strong documentation.

C. Sendability

  • Add Sendable to value types that cross concurrency domains.
  • For reference types: make them final and internally synchronized before conforming. If you must use @unchecked Sendable, document why it’s safe and contain usage.

D. Protocols & delegates

  • If conformers are main‑actor‑bound, make the protocol @MainActor to avoid “actor‑isolated requirement” errors.

E. Closures

  • Mark cross‑task closures @Sendable. Audit captures (weak self, immutable snapshots) to satisfy sendability.

F. Objective‑C / C interop

  • Audit imported types used across threads; confine them to one actor or wrap them behind an actor/Swift facade.

4) Stage your module migrations

For each framework:

  1. Fix warnings under Swift 5 mode + Complete checks.
  2. Flip the module to Swift Language Version = Swift 6 (or // swift-tools-version: 6.0 for SPM). Some warnings become errors; clean them.
  3. Protect downstream Swift 5 clients temporarily with @preconcurrency (on your declarations or on the import side). Track and remove over time.

5) CI, testing, and gating

  • Add a mixed‑mode lane: build everything in Swift 5 with strict checks = Complete (warnings), plus any modules already on Swift 6.
  • Add a Swift 6 lane that builds the migrated subset with language mode 6 to prevent regressions.
  • Keep TSAN enabled in tests. Static checks remove many races; TSAN still catches logic issues.

6) Typical fixes you’ll apply a lot

  • “Global variable is not actor‑isolated” → move into an actor, mark @MainActor, or make it immutable.
  • “Non‑Sendable captured in @Sendable closure” → make the type Sendable, capture a value copy, or confine to an actor.
  • “Main‑actor‑isolated method doesn’t satisfy protocol” → mark the protocol @MainActor (preferred) or use @preconcurrency temporarily.

7) Don’ts (learned the hard way)

  • Don’t flip everything at once. You’ll drown in errors—do leaves first.
  • Don’t blanket @preconcurrency import third‑party libs. You’re silencing safety checks; keep a ticket to remove them.
  • Don’t over‑actor your code. Prefer immutability and clear ownership; use actors for genuine shared mutable state.

8) Final cutover checklist

  • All frameworks compile in Swift 6 with zero concurrency diagnostics.
  • No remaining @unchecked Sendable without solid justification.
  • No lingering @preconcurrency unless documented and ticketed for removal.
  • App target flips to Swift 6 and ships.

Tip: Keep these as PR templates. Checking the boxes makes progress visible and prevents backsliding.

Where to go next

Tutorials:

Proposals:

Testing & diagnostics:

If this guide saved you a few hours, keep it handy for the next module. Migration is a series of small good decisions, not one big one.

Leave a comment

HOME

Hope you’ll enjoy Swifty Posts!