Refactoring Legacy Code in Swift: From A to Z
\nWorking with legacy code is an inevitable part of an iOS/macOS developer's life. Often, this is code written in early versions of Swift or even Objective-C, lacking tests, with "magic numbers" and giant UIViewControllers. In this article, we will break down a step-by-step strategy for refactoring such code, turning it into a modern, maintainable, and testable project.
1. Preparation and Safety: Before Touching the Code
\nThe most important rule of refactoring: don't break what works. Without tests, any change is a leap into the unknown.
\n- \n
- Characterization Tests: Write tests that capture the current behavior. Even if the code seems wrong, first document how it works. \n
- Version Control System: Commit the current state. Use a feature branch. \n
- Tools: Xcode with
Edit > Refactor(Rename, Extract Method), as well as linters (SwiftLint) to enforce a consistent style. \n
// Example of "freezing" behavior for an old method\nclass LegacyCalculator {\n func calculateTotal(items: [Int]) -> Int {\n // Old, unreadable logic\n var result = 0\n for i in items {\n result += i * 2\n }\n return result + 10\n }\n}\n\n// Test capturing the behavior\nfunc testCalculateTotal() {\n let sut = LegacyCalculator()\n let result = sut.calculateTotal(items: [1, 2, 3])\n XCTAssertEqual(result, 22) // (1+2+3)*2 + 10 = 22\n}\n\n\n2. The "Whipping Boy" Strategy: Global Variables and Singletons
\nLegacy code is notoriously known for its global states and singletons. This makes the code tightly coupled and unpredictable.
\nProblem:
\nclass NetworkManager {\n static let shared = NetworkManager()\n var baseURL: String = "http://old.api.com"\n func fetchData() { ... }\n}\n\nSolution: Dependency Injection. Create a protocol and pass dependencies through the initializer.
\nprotocol NetworkService {\n func fetchData()\n}\n\nclass ModernViewModel {\n private let network: NetworkService\n \n init(network: NetworkService) {\n self.network = network\n }\n \n func load() {\n network.fetchData()\n }\n}\n\nThis approach makes it easy to swap services in tests and makes the code modular.
\n\n3. Fighting the "Massive View Controller"
\nA classic iOS legacy: a UIViewController that handles everything — from network requests to UI formatting. Refactoring is done by separating responsibilities (SRP).
Step 1: Extract business logic into a separate model (ViewModel).
\n// Was in the controller\nfunc buttonTapped() {\n let data = fetchData()\n let total = data.reduce(0, +)\n label.text = "Sum: \\(total)"\n}\n\n// Now: ViewModel\nclass MyViewModel {\n func calculateTotal(data: [Int]) -> String {\n let total = data.reduce(0, +)\n return "Sum: \\(total)"\n }\n}\n\n\nStep 2: Move UI setup into separate methods or factories.
\n- \n
- Create a
ViewBuilderclass for complex hierarchies. \n - Use
UICollectionViewDiffableDataSourceinstead of the olddataSource. \n
4. Modernization: From Objective-C to Swift and New APIs
\nLegacy code often contains a mix of Objective-C and Swift. When refactoring, it's important to update outdated constructs:
\n\n- \n
- Replace
NSErrorwiththrows: Modern Swift uses exceptions for error handling. \n - Eliminate
!(