TL;DR: Here’s how we migrated 3 production Swift apps to Swift 6 Concurrency in 12 weeks — without pausing feature delivery.

©2025 - Bruno Valente Pimentel
©2025 - Bruno Valente Pimentel

In Part 1 — Swift Concurrency: What I Wish Someone Had Told Me — I shared the chaos of adopting Swift 6’s strict concurrency: @ surprises, Sendable pitfalls, and debugging sessions that stretched into the night.

This time, I’ll show you what came next — how we turned that pain into a repeatable framework that migrated three live iOS apps (~400K lines) in 12 weeks while still shipping new features every sprint.

If your org is facing the same modernization challenge — how to evolve without pausing delivery — this playbook is for you.

Where We Started

Our starting point looked painfully familiar:

  • Recurring concurrency-related crashes and UI freezes
  • QA cycles taking 3–4 days
  • Mixed async styles — GCD, Combine, callbacks, RxSwift
  • Devs hesitant to touch threaded code
The roadmap was locked. No pause window. We had to modernize while shipping.
Diagram, Architecture Evolution. Before: a View Controller talks to the Model through nested callbacks (callback hell). After: a ViewModel receives the Model through dependency injection and uses async/await on the @MainActor with TaskGroup.

1. Architecture First

Our layered stack (Domain → Data → Presentation / MVVM) became the foundation for concurrency ownership and visibility.

Diagram, Architecture First. Presentation sits before an async boundary; it calls the Domain layer, where actors own mutable state, which in turn calls the Data layer.

Three ground rules

  1. Actors own mutable state — no shared data across task boundaries.
  2. Async APIs stop at layer edges — Presentation layer calls async, never dips into Data internals.
  3. Legacy async code stays behind adapters — safe wrapping over rewrites.
Swift
@MainActor
final class UserViewModel: ObservableObject {
    @Published var users: [User] = []
    private let service: UserService

    init(service: UserService) {
        self.service = service
    }

    func fetchUsers() async {
        do {
            users = try await service.load()
        } catch {
            print("Error: \(error)")
        }
    }
}

This made concurrency visible. Developers now had a clear mental map:

“When you cross a layer boundary, you cross a concurrency boundary.”

New hires were productive in days, not weeks.

Diagram, Architecture Evolution. Before: Business Logic is tightly coupled to the Database. After: a Service talks to an Interface in both directions and depends on a Repository, a domain-driven design.

2. Testing Before Refactoring

Before touching production code, we expanded our concurrency regression suite. Goal: not perfect coverage — just catching the bugs that matter.

Swift
func testConcurrentAccess() async throws {
    let manager = DataManager()
    await withThrowingTaskGroup(of: Void.self) { group in
        for i in 0..<100 {
            group.addTask { try await manager.load(id: i) }
        }
        for try await _ in group {}
    }
}

These stress tests found 12+ hidden race conditions static analysis never flagged. Confidence came before code.

3. Adapters Keep Delivery Flowing

No big-bang rewrites. We wrapped legacy callbacks behind async boundaries.

Swift
// MARK: - Legacy Service (Callback-based)
class LegacyNetworkService {
    func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
        // Legacy implementation
    }
}
Adapters bought us time. Old code stayed safe, new code shipped modern. Velocity never stopped.

Adapters bought us time. Old code stayed safe, new code shipped modern. Velocity never stopped.

Swift
// MARK: - Modern Service (Async/Await)
actor ModernNetworkService {
    private let legacyService = LegacyNetworkService()

    /// Fetches data using modern async/await while wrapping legacy callback API
    func fetchData() async throws -> Data {
        try await withCheckedThrowingContinuation { continuation in
            self.legacyService.fetchData { result in
                continuation.resume(with: result)
            }
        }
    }
}
Swift
// MARK: - Usage Example
func example() async {
    let service = ModernNetworkService()

    do {
        let data = try await service.fetchData()
        print("Data fetched successfully: \(data.count) bytes")
    } catch {
        print("Error fetching data: \(error)")
    }
}

// MARK: - Alternative: More Flexible Implementation
actor FlexibleNetworkService {
    private let legacyService = LegacyNetworkService()
    private var cache: Data?

    /// Fetches data with optional caching
    func fetchData(useCache: Bool = true) async throws -> Data {
        if useCache, let cached = cache {
            return cached
        }

        let data = try await withCheckedThrowingContinuation { continuation in
            self.legacyService.fetchData { result in
                continuation.resume(with: result)
            }
        }

        self.cache = data
        return data
    }

    func clearCache() {
        cache = nil
    }
}
}
Diagram, Testing Impact. Before: tests led to slow progress. After: tests talk to an Adapter in both directions and lead to rapid progress. Adapters keep velocity up.

4. Leadership & Momentum

We introduced a modernization sprint track running parallel to feature work:

one module migrated → tested → shipped every three weeks.

Timeline, Migration Timeline. Week 1: tests. Week 2: migration. Week 3: review and release.
Every sprint ended with visible wins — compiler warnings down, crashes down, coverage up. That visibility built trust with leadership.

5. Measurable Impact in 12 Weeks

(5 engineers • 3 production apps • ~400K LOC)

Bar chart, 12-Week Impact Summary: From Chaos to Clarity. Concurrency-related crashes down 95 percent. QA cycle time 40 percent faster. Onboarding time 80 percent faster.

6. Why It Matters

This wasn’t just a technical upgrade — it was operational maturity.

• Fewer production incidents → measurable KPI improvements

•Clearer ownership → onboarding cut from 2 weeks → 3 days

• Documented ROI → stronger executive confidence

When you can tie refactors to delivery metrics, technical work stops being a cost and becomes a strategic advantage.

7. The Human Result

Eight weeks in, a mid-level engineer shipped a new real-time module using only Swift 6 Concurrency. No escalations. No thread issues. Just clean, reliable async code.

That’s when it clicked — we hadn’t just migrated code. We had migrated how the team thinks.

Key Takeaways

• Architecture clarity + testing discipline + visible metrics = predictable migration success

• Async/Await simplifies logic without slowing delivery

• Visibility builds trust — data beats opinion every time

• Progress > perfection — sustainable modernization wins long-term

We didn’t just modernize Swift code — we modernized how we deliver software. From “fix fast” → ship smart.

Who This Helps

• Product Teams: Learn how to deliver modernization safely under real deadlines.

• Engineering Leaders: Case study–level visibility on ROI-driven refactors.

• Developers: Practical Swift 6 playbooks and testing strategies for real teams.Follow & Connect

Follow & Connect

Follow Swift in Practice for proven Swift architecture patterns, modernization frameworks, and real-world concurrency results from production apps. I share lessons from leading iOS migrations and team transformations — the kind that keep delivery on track while evolving the tech stack.

☕ Enjoying Swift in Practice? It's free and reader-supported. If an edition saved you some debugging time, you can buy me a coffee: https://buymeacoffee.com/swiftinpractice

Originally published as a Swift in Practice edition on LinkedIn. Comments and discussion happen there.