iOS Development

How to refactor a legacy iOS app without blocking product development

13 min readRafał Dubiel
#iOS#Swift#Architecture#SwiftUI#Refactoring#Clean Architecture

If you’ve ever heard (or said yourself) the sentence “let’s rewrite it from scratch, it’ll be faster” while simultaneously feeling business pressure to keep shipping new features, you probably also remember how that story ended. Spoiler: usually not very well.

Let’s assume we’re dealing with a monolithic app: MVP + Coordinator, UIKit, and XcodeGen. The goal? Rewrite the app using Clean Architecture, SwiftUI, MVVM, and Tuist. In theory, this sounds like a guaranteed multi-month feature freeze and a frustrated team. But does it really have to be that way? As it turns out - not necessarily.


Why we don’t rewrite the app from scratch

In 2001, while observing fig trees in Queensland, Martin Fowler noticed a fascinating pattern: the strangler fig germinates in the canopy of a host tree and gradually wraps it with its roots until it eventually replaces it entirely. This is a perfect metaphor for migrating legacy code and the foundation of our strategy.

Why is a big bang rewrite a bad idea? Industry research and experience are very clear:

  • Development paralysis - for months (or years?) the team rewrites existing functionality instead of delivering new value to users,
  • Chasing a moving target - the business doesn’t stand still; new requirements appear faster than the rewrite progresses,
  • Loss of domain knowledge - legacy code, ugly as it may be, contains years of edge-case handling that is often undocumented,
  • Risk of disaster - if something goes wrong (and it will), rollback is impossible,
  • 3–4× underestimation - developers consistently underestimate rewrite complexity, typically by 300–400%!

Chris Richardson, author of Microservices Patterns, puts it bluntly: “Any time you engage in a big bang modernization effort, you are taking a huge risk, and the odds are not in your favor.” Article link


1. Diagnosis: understand what you have before you change it

Mapping the current state

Before you write a single line of new code, you must precisely understand what you’re dealing with. As Ignas Pileckas writes in his case study on refactoring a UIKit + Storyboards monolith: “Before tackling a project like this, you need to evaluate where are the biggest pain points.” Article link

Key diagnostic questions:

  1. What does the dependency graph look like? Which modules/classes are the most coupled?
  2. Where is the business logic? In Presenters? ViewControllers? Scattered everywhere?
  3. How does navigation work? Are coordinators truly independent, or is it spaghetti?
  4. What is the test coverage? Which areas are testable, and which are not?
  5. What hurts the team the most? Build times? Merge conflicts? Onboarding difficulty?

Typical problems of an MVP + Coordinator monolith

ProblemManifestation
Massive presentersPresenters with 1000+ lines of code mixing business logic, formatting, and navigation
XcodeGen limitationsYAML doesn’t scale - lack of reusability, templates are workarounds, not real solutions
UIKit boilerplateHundreds of lines for a simple table, manual lifecycle handling, memory leaks from retain cycles
Coordinator couplingCoordinators know too much about each other, deep child-coordinator hierarchies
Build timeClean build 10+ minutes, incremental build 2–3 minutes for small changes
Callback hellNested completion handlers instead of async/await, hard to test and debug

2. Strategy: Strangler Fig in a mobile context

What does Strangler Fig mean for a mobile app?

Thoughtworks, in “Using the Strangler Fig with Mobile Apps”, describes applying this pattern in mobile as “incremental replacement of a legacy mobile application.” The key difference compared to backend systems: in mobile we don’t have a proxy or gateway - but we do have something equally powerful: the composition layer in AppDelegate / SceneDelegate.

Fundamental principles:

  • Containment, not conversion - you don’t convert the app; you replace its edges, one feature at a time,
  • UIKit remains the host (at first) - SwiftUI starts as a “guest” inside a UIHostingController,
  • ViewModels are agnostic - the same ViewModel can power both UIKit and SwiftUI,
  • Never migrate navigation first - this is the most common mistake, navigation should be migrated last.

Order of operations - a practical roadmap

Based on current industry patterns and real-world experience:

  1. Phase -1 (Week 0–2): safety infrastructure - metrics, feature flags, positive-path E2E tests (XCUITest / Maestro),
  2. Phase 0 (Months 0–2): domain layer - extract use cases, repository protocols, migrate the worst callbacks to async/await,
  3. Phase 1 (Months 2–4): quick wins - 1–3 small but business-critical and visually simple screens in SwiftUI + @Observable,
  4. Phase 2 (Months 4–6): core modules - extract infrastructure (networking, storage, DI),
  5. Phase 3 (Months 6–10): Tuist + modular monolith - migrate from XcodeGen, enable caching (huge DX boost),
  6. Phase 4 (Months 10–14): more SwiftUI features - gradual deprecation of legacy flows,
  7. Phase 5 (Months 14+): navigation - first hybrid NavigationStack + legacy coordinators, then full SwiftUI.

Why quick wins before core? In previous years, the opposite order was recommended. Experience shows that early, visible results are crucial for team morale and business trust. One migrated screen is tangible proof that the strategy works.


3. Phase -1: safety infrastructure

Before touching the code

This step is often skipped - and that’s a mistake. Without it, you can’t measure success or safely roll back changes.

Baseline metrics:

  • Build time (clean and incremental),
  • Test coverage,
  • Crash rate,
  • App startup time,
  • Binary size.

Feature flags:

Every migrated feature must be behind a feature flag. If something goes wrong in production, you flip the switch and revert instantly.

// FeatureFlags.swift
enum FeatureFlag: String {
    case newProfileScreen
    case swiftUIPaymentFlow
    case asyncNetworking

    var isEnabled: Bool {
        RemoteConfig.shared.isEnabled(rawValue)
    }
}

// Usage
if FeatureFlag.newProfileScreen.isEnabled {
    coordinator.showNewProfileScreen() // SwiftUI
} else {
    coordinator.showLegacyProfileScreen() // UIKit
}

Golden path E2E tests:

At least 5–10 E2E tests covering critical user paths. These tests must pass before and after every migration.


4. Phase 0: domain layer + async/await

Extracting the domain layer

Even within a monolith, create a Domain folder/group containing pure domain models and use cases. These classes must not import UIKit or any UI frameworks.

Migrating callbacks → async/await

In 2026, async/await is the standard. Legacy completion handlers and Combine for simple operations are considered outdated. Migration is the perfect moment to switch.

// BEFORE - callback hell
class ProfilePresenter {
    func loadProfile() {
        networkService.fetchProfile { [weak self] result in
            switch result {
            case .success(let profile):
                self?.storageService.cacheProfile(profile) { error in
                    if let error = error {
                        self?.view?.showError(error)
                    } else {
                        self?.analyticsService.track(.profileLoaded) { _ in
                            self?.view?.display(profile)
                        }
                    }
                }
            case .failure(let error):
                self?.view?.showError(error)
            }
        }
    }
}

// AFTER - async/await
final class FetchProfileUseCase {
    private let profileRepository: ProfileRepository
    private let analytics: AnalyticsService

    init(profileRepository: ProfileRepository, analytics: AnalyticsService) {
        self.profileRepository = profileRepository
        self.analytics = analytics
    }

    func execute() async throws -> Profile {
        let profile = try await profileRepository.fetchProfile()
        try await profileRepository.cacheProfile(profile)
        await analytics.track(.profileLoaded)
        return profile
    }
}

Repository protocols

// Domain/Repositories/ProfileRepository.swift
protocol ProfileRepository: Sendable {
    func fetchProfile() async throws -> Profile
    func cacheProfile(_ profile: Profile) async throws
    func getCachedProfile() async -> Profile?
}

// Data/Repositories/ProfileRepositoryImpl.swift
final class ProfileRepositoryImpl: ProfileRepository {
    private let apiClient: APIClient
    private let storage: LocalStorage

    func fetchProfile() async throws -> Profile {
        try await apiClient.request(ProfileEndpoint.get)
    }

    func cacheProfile(_ profile: Profile) async throws {
        try await storage.save(profile, forKey: .profile)
    }

    func getCachedProfile() async -> Profile? {
        await storage.load(forKey: .profile)
    }
}

5. Target architecture: Clean Architecture + MVVM + @Observable

Why MVVM instead of staying with MVP?

The decision to move from MVP to MVVM when migrating to SwiftUI is deliberate:

AspectMVPMVVM + @Observable
View bindingManual via view protocolAutomatic via @Observable
SwiftUI fitRequires adaptationNative - @Observable macro
Relationship1:1 (View : Presenter)1:N (one ViewModel, multiple views)
TestabilityRequires mocking view protocolsViewModel tested in isolation
BoilerplateHighMinimal

@Observable vs ObservableObject

In 2026, almost no one uses ObservableObject + @Published in new code. The @Observable macro (iOS 17+) is more efficient and simpler.

// OLD (ObservableObject) - avoid in new code
class ProfileViewModel: ObservableObject {
    @Published var profile: Profile?
    @Published var isLoading = false
    @Published var error: Error?
}

// NEW (@Observable) - 2026 standard
@Observable
final class ProfileViewModel {
    var profile: Profile?
    var isLoading = false
    var error: Error?

    private let fetchProfileUseCase: FetchProfileUseCase

    init(fetchProfileUseCase: FetchProfileUseCase) {
        self.fetchProfileUseCase = fetchProfileUseCase
    }

    func loadProfile() async {
        isLoading = true
        defer { isLoading = false }

        do {
            profile = try await fetchProfileUseCase.execute()
        } catch {
            self.error = error
        }
    }
}

6. Migrating from XcodeGen to Tuist

Tuist won this war

In 2025–2026, Tuist became the de facto standard. XcodeGen is now seen as legacy (though still functional). That said, XcodeGen is not a bad tool - in many mature apps it can serve as an acceptable transitional step.

Key reasons:

  • Swift instead of YAML - autocomplete, type checking, compile-time safety,
  • Caching - combined with Xcode 26, compilation cache is a massive DX boost,
  • ProjectDescriptionHelpers - native reuse via Swift modules,
  • Focused projects - generate only the modules you’re working on.

Migration strategy

// Tuist/ProjectDescriptionHelpers/Module.swift
import ProjectDescription

public enum Module: String, CaseIterable {
    case core
    case networking
    case profile
    case payment

    public var target: Target {
        .target(
            name: name,
            destinations: .iOS,
            product: .framework,
            bundleId: "com.app.\(rawValue)",
            sources: ["Sources/\(name)/**"],
            dependencies: dependencies,
            settings: .settings(
                base: [
                    "SWIFT_STRICT_CONCURRENCY": "complete"
                ]
            )
        )
    }

    public var testTarget: Target {
        .target(
            name: "\(name)Tests",
            destinations: .iOS,
            product: .unitTests,
            bundleId: "com.app.\(rawValue).tests",
            sources: ["Tests/\(name)Tests/**"],
            dependencies: [.target(name: name)]
        )
    }

    private var name: String { rawValue.capitalized }

    private var dependencies: [TargetDependency] {
        switch self {
        case .core: return []
        case .networking: return [.target(name: Module.core.name)]
        case .profile: return [.target(name: Module.networking.name)]
        case .payment: return [.target(name: Module.networking.name)]
        }
    }
}

7. Swift Testing instead of XCTest

Migration is the perfect time to move from XCTest to Swift Testing. In 2026, this is the default choice for new modules.

// OLD (XCTest)
import XCTest
@testable import Profile

final class ProfileViewModelTests: XCTestCase {
    func testLoadProfileSuccess() async throws {
        let mockRepository = MockProfileRepository()
        mockRepository.fetchProfileResult = .success(.mock)

        let sut = ProfileViewModel(
            fetchProfileUseCase: FetchProfileUseCase(
                profileRepository: mockRepository,
                analytics: MockAnalytics()
            )
        )

        await sut.loadProfile()

        XCTAssertEqual(sut.profile, .mock)
        XCTAssertFalse(sut.isLoading)
        XCTAssertNil(sut.error)
    }
}

// NEW (Swift Testing) - 2026 standard
import Testing
@testable import Profile

@Suite("ProfileViewModel")
struct ProfileViewModelTests {

    @Test("loads profile successfully")
    func loadProfileSuccess() async throws {
        let mockRepository = MockProfileRepository()
        mockRepository.fetchProfileResult = .success(.mock)

        let sut = ProfileViewModel(
            fetchProfileUseCase: FetchProfileUseCase(
                profileRepository: mockRepository,
                analytics: MockAnalytics()
            )
        )

        await sut.loadProfile()

        #expect(sut.profile == .mock)
        #expect(!sut.isLoading)
        #expect(sut.error == nil)
    }

    @Test("shows error on failure", arguments: [
        NetworkError.noConnection,
        NetworkError.timeout,
        NetworkError.serverError(500)
    ])
    func loadProfileFailure(error: NetworkError) async {
        let mockRepository = MockProfileRepository()
        mockRepository.fetchProfileResult = .failure(error)

        let sut = ProfileViewModel(/* ... */)

        await sut.loadProfile()

        #expect(sut.profile == nil)
        #expect(sut.error != nil)
    }
}

Advantages of Swift Testing:

  • Cleaner syntax (#expect instead of XCTAssert*),
  • Parameterized tests (arguments:),
  • Better grouping (@Suite),
  • Faster execution (parallelism),
  • Native integration with Swift Concurrency.

8. Navigation migration: from coordinator to SwiftUI NavigationStack

Why last?

Navigation is the glue that holds all screens together. Migrating it early means maintaining two navigation systems in parallel for the entire process. You really don’t want that.

Hybrid approach

A common pattern today is a hybrid NavigationStack with UIKit child controllers:

// Hybrid: SwiftUI NavigationStack with UIKit child
struct MainTabView: View {
    @State private var profilePath = NavigationPath()

    var body: some View {
        TabView {
            NavigationStack(path: $profilePath) {
                ProfileView()
                    .navigationDestination(for: ProfileRoute.self) { route in
                        switch route {
                        case .edit:
                            EditProfileView()
                        case .settings:
                            LegacySettingsViewController.asSwiftUIView()
                        }
                    }
            }
            .tabItem { Label("Profile", systemImage: "person") }
        }
    }
}

Target router pattern

Increasingly, teams replace classic coordinators with a router object:

@Observable
final class AppRouter {
    var path = NavigationPath()
    var sheet: Sheet?
    var fullScreenCover: FullScreenCover?

    enum Route: Hashable {
        case profile
        case editProfile
        case settings
        case paymentFlow(PaymentContext)
    }

    enum Sheet: Identifiable {
        case share(URL)
        case filter(FilterOptions)
        var id: String { String(describing: self) }
    }

    enum FullScreenCover: Identifiable {
        case onboarding
        case camera
        var id: String { String(describing: self) }
    }

    func push(_ route: Route) { path.append(route) }
    func pop() { guard !path.isEmpty else { return }; path.removeLast() }
    func popToRoot() { path.removeLast(path.count) }
    func present(_ sheet: Sheet) { self.sheet = sheet }
    func presentFullScreen(_ cover: FullScreenCover) { self.fullScreenCover = cover }
}

9. What to avoid: anti-patterns and pitfalls

Technical pitfalls

  • Over-modularization - don’t create a module for every tiny feature,
  • Distributed monolith - many synchronously communicating modules are worse than a monolith,
  • Premature SwiftUI adoption - iOS 17+ is the safest baseline,
  • Circular dependencies - detect early using dependency graphs,
  • Ignoring strict concurrency - enable SWIFT_STRICT_CONCURRENCY=complete from day one.

Process pitfalls

  • Feature freeze - refactoring must run in parallel with feature work,
  • Big PRs - migrate incrementally,
  • No metrics - measure build time, coverage, crash rate before and after,
  • Hero culture - knowledge must be shared,
  • Skipping phase -1 - without safety nets, you’re gambling.

10. Team checklist

Before starting (phase -1)

  • Baseline metrics measured,
  • Feature flag system ready,
  • E2E tests for critical paths passing,
  • Business aligned on a 12+ month effort,
  • Team trained in SwiftUI and async/await,
  • 2–3 pilot screens identified.

During migration

  • Every change is production-ready,
  • New features use the new architecture,
  • Feature flags active,
  • Metrics reviewed regularly,
  • Code reviews distributed.

Warning signs 🚨

  • Build times increase,
  • Crash rate rises,
  • Team spends more time fixing than migrating,
  • New features are blocked,
  • Only one person understands the architecture.

Summary: choose a marathon, not a sprint

Migrating a legacy app is a marathon measured in months or years - not a sprint.

Key principles:

  1. Strangler Fig > big bang,
  2. Quick wins early,
  3. @Observable + async/await are non-negotiable,
  4. Swift Testing over XCTest,
  5. Tuist + caching,
  6. Feature flags everywhere,
  7. Measure, don’t assume.

Remember: the worst code is code that doesn’t work. Your legacy monolith, despite all its flaws, works in production and delivers value. Every change must preserve that.

Good luck with your migration!

Rafał Dubiel

Rafał Dubiel

Senior iOS Developer with 8+ years of experience building mobile applications. Passionate about clean architecture, Swift, and sharing knowledge with the community.

Share this article: