Introduction: Why legacy Swift code needs refactoring
Working with legacy code is an inevitable part of an iOS/macOS developer's life. Even if a project was originally written in Swift, over time it accumulates technical debt: outdated patterns, lack of tests, monolithic architecture, and mixing Swift with Objective-C. Refactoring such code not only improves readability — it reduces maintenance costs, speeds up adding new features, and decreases the number of bugs.
In this article, we'll cover specific techniques for refactoring legacy Swift code: from eliminating force unwrapping to migrating from MVC to MVVM. All examples can be applied to both iOS and macOS projects.
1. Assessment and preparation: where to start refactoring
Before changing code, it's important to understand its current state. Use Xcode's built-in tools and metrics.
1.1. Analyzing warnings and errors
Open the project in Xcode and go to Report Navigator (Cmd+9). Pay attention to:
- Yellow warnings — deprecated APIs, unused variables.
- Red errors — syntax errors that may result from incomplete Swift migration.
- SwiftLint/SwiftFormat — connect a linter to automatically detect code style violations.
1.2. Searching for "red flags" in the code
Scan through the project using search (Cmd+Shift+F) for the following patterns:
!— force unwrapping (optional values that crash the app).as!— force casting (type casting without checking).try!— ignoring errors.NSObject— inheriting from NSObject without necessity (pure Swift doesn't require this).#selector— string selectors, which are better replaced with#selectorwith type.
// Bad (legacy)let name = user.name! // force unwrap — crash on nil
// Good (refactoring)guard let name = user.name else { return}1.3. Writing tests before changes
Refactoring without tests is risky. If there are no tests, start by adding them for critical modules. Use XCTest for unit tests and XCUITest for UI.
Example of a simple test for a legacy function:
class LegacyCalculatorTests: XCTestCase { func testCalculateTotal() { let calculator = LegacyCalculator() let result = calculator.calculateTotal(items: [10, 20, 30]) XCTAssertEqual(result, 60) }}2. Basic techniques for refactoring Swift code
Let's move on to practical steps that can be applied right now.
2.1. Replacing force unwrapping with safe patterns
The most common problem in legacy code is the widespread use of !. Replace them with guard let, if let, or ?? (nil-coalescing).
// Before refactoringfunc getUserName() -> String { return user.name!}
// After refactoringfunc getUserName() -> String { return user.name ?? "Unknown"}2.2. Eliminating Massive View Controller (MVC → MVVM)
Legacy projects often suffer from "fat" controllers. Extract business logic into separate services or ViewModel.
// Legacy ViewController (fragment)class ProfileViewController: UIViewController { @IBOutlet weak var nameLabel: UILabel! override func viewDidLoad() { super.viewDidLoad() // Business logic directly in the controller let user = DataManager.shared.fetchUser() nameLabel.text = user.name?.capitalized }}
// After refactoringclass ProfileViewModel { private let userService: UserService var userName: String { return userService.currentUs