Introduction: Why Refactoring Legacy Code in Swift is an Art
Working with legacy code in Swift is one of the most complex and responsible tasks for an iOS/macOS developer. Legacy projects often contain "God objects", excessive dependencies on UIKit, a lack of tests, and chaotic architecture. Advanced refactoring is not just about renaming variables; it's a systematic transformation of the codebase while preserving 100% functionality. In this article, we will explore techniques that help turn chaos into a maintainable architecture.
1. Modularization: Breaking Down the Monolith into Components
The first step towards civilized code is isolating independent modules. Instead of one giant target, create separate frameworks or Swift Package Manager modules.
Defining Module Boundaries
Use the "High Cohesion, Low Coupling" principle. Example: separate the network layer, business logic layer, and UI layer.
// Before: everything in one ViewController fileclass LegacyViewController: UIViewController { func fetchData() { // URLSession code here } func parseData() { // JSON parsing here }}
// After: Network modulepublic class NetworkService { public func fetchData(completion: @escaping (Result<Data, Error>) -> Void) { // Clean network layer }}Migration via Facades
Don't break existing code: create a facade that delegates calls to the new module. Gradually replace old calls.
class LegacyNetworkFacade { private let newService = NetworkService() @objc func oldFetchMethod() { newService.fetchData { result in // Adaptation for the old API } }}2. Dependency Injection (DI) Instead of Global Singletons
Legacy code often suffers from singletons: UserManager.shared, APIClient.shared. This makes testing impossible. Switch to DI.
Manual Injection via Initializer
// Problemclass OldViewModel { let api = APIClient.shared let user = UserManager.shared}
// Solutionprotocol APIClientProtocol { /* ... */ }protocol UserManagerProtocol { /* ... */ }
class NewViewModel { private let api: APIClientProtocol private let userManager: UserManagerProtocol init(api: APIClientProtocol, userManager: UserManagerProtocol) { self.api = api self.userManager = userManager }}Integrating a DI Container (Swinject/Resolver)
For large projects, use libraries. Example with Resolver:
import Resolver
extension Resolver: ResolverRegistering { public static func registerAllServices() { register { NetworkService() as NetworkServiceProtocol } register { UserDefaultsStorage() as StorageProtocol } }}
class RefactoredViewModel { @Injected private var network: NetworkServiceProtocol @Injected private var storage: StorageProtocol}3. Testing: Refactoring Under the Protection of Tests
Without tests, refactoring is like playing Russian roulette. Create a "test harness" (characterization tests) for the old code.
Characterization Tests for Legacy Code
Record the current behavior as tests, even if it's not ideal.
class LegacyCalculatorTests: XCTestCase { func testLegacyMultiply() { // Capture the current behavior let result = LegacyCalculator().multiply(2, 3) XCTAssertEqual(result, 6) // Even if the logic is strange }}Parallel Refactoring with Golden Master
Compare the output of the old and new implementations. Use XCTAssertEqual to verify identity.
func testRefactoredMatchesL