Refactoring Legacy Code in Swift: Advanced Techniques

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

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                

Blogs

Book Recommendations