Refactoring Legacy Code in Swift: A Step-by-Step Guide for iOS/macOS

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 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 #selector with 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

Blogs

Book Recommendations