How to refactor a legacy iOS app without blocking product development
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:
- What does the dependency graph look like? Which modules/classes are the most coupled?
- Where is the business logic? In Presenters? ViewControllers? Scattered everywhere?
- How does navigation work? Are coordinators truly independent, or is it spaghetti?
- What is the test coverage? Which areas are testable, and which are not?
- What hurts the team the most? Build times? Merge conflicts? Onboarding difficulty?
Typical problems of an MVP + Coordinator monolith
| Problem | Manifestation |
|---|---|
| Massive presenters | Presenters with 1000+ lines of code mixing business logic, formatting, and navigation |
| XcodeGen limitations | YAML doesn’t scale - lack of reusability, templates are workarounds, not real solutions |
| UIKit boilerplate | Hundreds of lines for a simple table, manual lifecycle handling, memory leaks from retain cycles |
| Coordinator coupling | Coordinators know too much about each other, deep child-coordinator hierarchies |
| Build time | Clean build 10+ minutes, incremental build 2–3 minutes for small changes |
| Callback hell | Nested 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:
- Phase -1 (Week 0–2): safety infrastructure - metrics, feature flags, positive-path E2E tests (XCUITest / Maestro),
- Phase 0 (Months 0–2): domain layer - extract use cases, repository protocols, migrate the worst callbacks to async/await,
- Phase 1 (Months 2–4): quick wins - 1–3 small but business-critical and visually simple screens in SwiftUI +
@Observable, - Phase 2 (Months 4–6): core modules - extract infrastructure (networking, storage, DI),
- Phase 3 (Months 6–10): Tuist + modular monolith - migrate from XcodeGen, enable caching (huge DX boost),
- Phase 4 (Months 10–14): more SwiftUI features - gradual deprecation of legacy flows,
- 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:
| Aspect | MVP | MVVM + @Observable |
|---|---|---|
| View binding | Manual via view protocol | Automatic via @Observable |
| SwiftUI fit | Requires adaptation | Native - @Observable macro |
| Relationship | 1:1 (View : Presenter) | 1:N (one ViewModel, multiple views) |
| Testability | Requires mocking view protocols | ViewModel tested in isolation |
| Boilerplate | High | Minimal |
@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 (
#expectinstead ofXCTAssert*), - 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=completefrom 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:
- Strangler Fig > big bang,
- Quick wins early,
- @Observable + async/await are non-negotiable,
- Swift Testing over XCTest,
- Tuist + caching,
- Feature flags everywhere,
- 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
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: