---
title: 'Incremental Migration of Legacy Code'
source: 'https://academia.sh/en/courses/uikit/incremental-migration'
course: 'UIKit and Bridging'
language: en
updated: '2026-10-21T05:03:15+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Explains why converting legacy code screen by screen rather than all at once keeps two frameworks side by side'
    - 'Shows that a field not carried over to the new screen is measured as missing'
    - 'Shows that a field dropped from the model but left on the old screen is measured as leftover'
    - 'Checks the same model against two screens and confirms that the rule does not give a single answer at screen level'
---

# Incremental Migration of Legacy Code

Measuring that a field not carried over stays missing on the new screen while moving a legacy screen to the new framework one screen at a time, that a field dropped from the model stays leftover on the old screen, and that the rule works per field, not per screen.

In the previous lesson (Declarative and Imperative Interfaces Together) we looked at the seam between the two frameworks: if no assignment or update call was written while a value crossed the border, the field stayed stale. In a real application there is a second question beside that seam. Legacy code contains hundreds of screens and no team rewrites them all overnight. The conversion moves screen by screen: at each step one screen moves to the declarative framework and the rest keep working as they were. The application must stay shippable even in the middle of the conversion.

This means the two frameworks live side by side for a long time. The reasoning for the incremental path is simple: because each step is small, the source of a bug is known when one is found. When a screen is moved, the only thing that can break is that screen. In a one-time rewrite the same bug can come from anywhere in the application, and nobody knows when it got in. The order of moving usually runs from the leaves toward the root: a detail screen with few fields, which opens no other screen, is the first candidate, because it is small and easy to roll back if it fails. At the seam, both options from the previous lesson apply: a new screen is pushed onto an old navigation stack through a hosting controller, or an old piece enters a new screen through wrapping.

What we will measure in this lesson is which of the things the old screen did pass to the new screen when a screen is moved. The measuring axis has not changed: **agreement**. For each field one of four results comes out: `matching`, `stale`, `missing`, `leftover`. The core file is the same as in the previous lessons.

```swift
// Sync.swift — compares every field shown on screen with the model's current value
final class Screen {
    let name: String
    private(set) var shown: [String: String] = [:]

    init(_ name: String) { self.name = name }

    func set(_ key: String, _ value: String) { shown[key] = value }
    func clear(_ key: String) { shown[key] = nil }
}

func verdict(model: String?, shown: String?) -> String {
    switch (model, shown) {
    case (nil, nil): return "out-of-scope"
    case (nil, _?): return "leftover"
    case (_?, nil): return "missing"
    case let (wanted?, displayed?): return wanted == displayed ? "matching" : "stale"
    }
}

func check(_ label: String, model: [String: String], screen: Screen, keys: [String]) -> [String] {
    keys.map { key in
        let result = verdict(model: model[key], shown: screen.shown[key])
        print("\(label) | \(screen.name).\(key): model=\(model[key] ?? "-") screen=\(screen.shown[key] ?? "-") -> \(result)")
        return result
    }
}

func countVerdicts(_ verdicts: [String]) {
    for name in ["matching", "stale", "missing", "leftover"] {
        print("\(name): \(verdicts.filter { $0 == name }.count)")
    }
}
```

The things an old screen does are often not written down anywhere. The person who wrote the old screen has left, there is no documentation, the screen has grown over the years through small additions. The person writing the new screen reads the old one and rebuilds it, and may overlook a field while reading. The rule catches this oversight without caring who made it, by saying that the field is in the model but not on the screen. That is why the finish line of a conversion step is not "the screen opens"; it should be "every field of the model finds a counterpart on the screen". The first criterion is met by eye, the second by comparing field by field.

## A Field That Was Not Carried Over

We have a task screen. The old version is written in the imperative interface framework and shows three values: a title, an open task count and a due label. Each value is written to a label through an **outlet**; the code that writes runs in a method just before the screen appears. There is also an old badge on the screen.

```swift
import UIKit

final class LegacyTaskController: UIViewController {
    @IBOutlet private var titleLabel: UILabel!
    @IBOutlet private var countLabel: UILabel!
    @IBOutlet private var dueLabel: UILabel!
    @IBOutlet private var badgeLabel: UILabel!

    func bind(_ task: TaskModel) {
        titleLabel.text = task.title
        countLabel.text = String(task.openCount)
        dueLabel.text = task.dueLabel
        badgeLabel.text = "new"
    }
}
```

In the first step of the conversion the screen is rewritten in the declarative framework. The new view takes the title and the count and shows them. The due label was skipped during the rewrite.

```swift
import SwiftUI

struct TaskScreenView: View {
    let task: TaskModel

    var body: some View {
        VStack {
            Text(task.title)
            Text(String(task.openCount))
        }
    }
}
```

Comparing the two pieces, the difference can be seen, but on most real screens there are twelve fields and the difference goes unnoticed. The model builds both screens in plain Swift: who writes what to the screen. The shared types stay in a helper file; the names `TaskModel`, `LegacyTaskController`, `bind` and `body` in the SOURCE snippets are the same here. If `Label` is dropped from the end of the label names, the keys come out: `title` for `titleLabel`, `dueLabel` for `dueLabel`.

```swift
// Migration.swift — the model's value and the setup of the old screen
struct TaskModel {
    var title: String
    var openCount: Int
    var dueLabel: String

    var values: [String: String] {
        ["title": title, "openCount": String(openCount), "dueLabel": dueLabel]
    }
}

final class LegacyTaskController {
    let screen = Screen("LegacyTaskController")

    func bind(_ task: TaskModel) {
        screen.set("title", task.title)
        screen.set("openCount", String(task.openCount))
        screen.set("dueLabel", task.dueLabel)
        screen.set("badge", "new")
    }
}
```

The model builds what the code writes to the screen; it is not the framework itself. First event: the new screen is built without writing `dueLabel`, and the model asks for all three fields.

```swift
// main.swift — the new screen does not carry the dueLabel field
struct TaskScreenView {
    let task: TaskModel

    func body(on screen: Screen) {
        screen.set("title", task.title)
        screen.set("openCount", String(task.openCount))
    }
}

let task = TaskModel(title: "Tasks", openCount: 5, dueLabel: "tomorrow")
let migrated = Screen("TaskScreenView")
TaskScreenView(task: task).body(on: migrated)

let dropped = check("new-screen-missing", model: task.values, screen: migrated,
                    keys: ["title", "openCount", "dueLabel"])

countVerdicts(dropped)
```

```
new-screen-missing | TaskScreenView.title: model=Tasks screen=Tasks -> matching
new-screen-missing | TaskScreenView.openCount: model=5 screen=5 -> matching
new-screen-missing | TaskScreenView.dueLabel: model=tomorrow screen=- -> missing
matching: 2
stale: 0
missing: 1
leftover: 0
```

Two fields were carried over and are matching. `dueLabel` is `tomorrow` in the model and does not exist on the new screen: **missing**. This is different from `stale`. With stale there was an old value on screen; here the screen never wrote the field at all. The root cause comes from the same place as the bug in the previous lesson, an unwritten line, but the outcome is named separately because it is noticed differently. A stale number can be misread, while a missing label shows up on the screen as a blank.

**BR1.** The model assumes that every value the new screen writes in `body` has been written to the screen. When the declarative framework calls `body` and what it recomputes on a change are not measured; what is measured is which fields `body` supplies.

Fixing the missing field is short: add `Text(task.dueLabel)` to the new view. In the measurement we do this by writing one line in `body`.

```swift
// main.swift — dueLabel carried over to the new screen
struct TaskScreenView {
    let task: TaskModel

    func body(on screen: Screen) {
        screen.set("title", task.title)
        screen.set("openCount", String(task.openCount))
        screen.set("dueLabel", task.dueLabel)
    }
}

let task = TaskModel(title: "Tasks", openCount: 5, dueLabel: "tomorrow")
let migrated = Screen("TaskScreenView")
TaskScreenView(task: task).body(on: migrated)

let carried = check("new-screen-carried", model: task.values, screen: migrated,
                    keys: ["title", "openCount", "dueLabel"])

countVerdicts(carried)
```

```
new-screen-carried | TaskScreenView.title: model=Tasks screen=Tasks -> matching
new-screen-carried | TaskScreenView.openCount: model=5 screen=5 -> matching
new-screen-carried | TaskScreenView.dueLabel: model=tomorrow screen=tomorrow -> matching
matching: 3
stale: 0
missing: 0
leftover: 0
```

All three fields are matching. The conversion is complete for this field. One more point to note: this fix added one line to `body` and changed nothing else. This is the useful side of incremental conversion. The place of the bug is known, the fix is small, and after the fix the measurement shows with the same counts that the bug is gone.

## A Field Dropped From the Model

The flip side of conversion is a field the old screen still writes but that no longer corresponds to anything. Remember the badge: the old screen has the line `badgeLabel.text = "new"` and it runs unconditionally. Suppose the badge was removed by a product decision. The model no longer carries a `badge` field, but the old screen keeps writing it.

```swift
// main.swift — the model does not carry the badge field, the old screen keeps writing it
let task = TaskModel(title: "Tasks", openCount: 5, dueLabel: "tomorrow")
let legacy = LegacyTaskController()
legacy.bind(task)

let leftover = check("old-screen-leftover", model: task.values, screen: legacy.screen,
                     keys: ["title", "badge"])

countVerdicts(leftover)
```

```
old-screen-leftover | LegacyTaskController.title: model=Tasks screen=Tasks -> matching
old-screen-leftover | LegacyTaskController.badge: model=- screen=new -> leftover
matching: 1
stale: 0
missing: 0
leftover: 1
```

`title` is matching. For `badge` there is no model value (`-`) and the screen says `new`: **leftover**. Once more the rule does not see the cause; it cannot tell whether the field was removed or the old screen writes it by mistake. It says only that there is a value on the screen that the model does not know. Old code has many lines of this kind: the remnant of a removed feature, a constant text put in for debugging, a label nobody reads any more, and these are also the easiest to disappear when moving to a new screen; but when a field is not carried over too, missing and leftover give two different answers to the same question: what is in the model is not on the screen, what is on the screen is not in the model.

A leftover has two fixes and the measurement does not say which is right. If the badge was really removed, the line on the old screen is deleted and the field drops out of the result. If the badge was not removed, the model gives it a value again and the result becomes matching. The first fix changes the old screen, the second changes the model. The same distinction applies to missing: either the field is added to the new screen or the model drops the field. Who chooses which in a conversion is a product decision; the rule only shows that the two sides have diverged and keeps the divergence visible until a decision is made.

That is why an inventory step goes into the conversion plan. Every field the screen to be moved reads from the model is listed, and the constants the old screen writes on its own are marked separately. A screen moved without an inventory gives, in the measurement, the first two results, missing and leftover, together. Once the inventory is done, the check for each moved screen comes down to a single question: are this screen's fields the same as the old screen's fields and the model's fields?

## The Bounding Measurement

In the middle of the conversion the same model feeds two screens at once: the old screen is still in place, the new screen beside it. The question is whether "is this screen in agreement with the model" can be answered at screen level.

```swift
// main.swift — the same model is checked against two screens
struct TaskScreenView {
    let task: TaskModel

    func body(on screen: Screen) {
        screen.set("title", task.title)
        screen.set("openCount", String(task.openCount))
    }
}

let task = TaskModel(title: "Tasks", openCount: 5, dueLabel: "tomorrow")
let keys = ["title", "openCount", "dueLabel"]

let legacy = LegacyTaskController()
legacy.bind(task)
let migrated = Screen("TaskScreenView")
TaskScreenView(task: task).body(on: migrated)

let oldScreen = check("two-screens-old", model: task.values, screen: legacy.screen, keys: keys)
let newScreen = check("two-screens-new", model: task.values, screen: migrated, keys: keys)

countVerdicts(oldScreen + newScreen)
```

```
two-screens-old | LegacyTaskController.title: model=Tasks screen=Tasks -> matching
two-screens-old | LegacyTaskController.openCount: model=5 screen=5 -> matching
two-screens-old | LegacyTaskController.dueLabel: model=tomorrow screen=tomorrow -> matching
two-screens-new | TaskScreenView.title: model=Tasks screen=Tasks -> matching
two-screens-new | TaskScreenView.openCount: model=5 screen=5 -> matching
two-screens-new | TaskScreenView.dueLabel: model=tomorrow screen=- -> missing
matching: 5
stale: 0
missing: 1
leftover: 0
```

The old screen is matching in all three fields, the new screen is matching in two and missing in one. In total, five matching, one missing. From this total one could build a sentence like "eighty-three percent of the application is in agreement", but that sentence does not say which of the screens is consistent. If the user sees only the new screen, the screen is broken in their eyes; if they see only the old one, there is no problem. There is no single answer at screen level, **the rule works per field**. For the same reason, declaring a screen "matching" says that every field written is equal to the model; a field the rule does not know, if it is in the model but stays missing on the screen, shows up only in the count.

This measurement also names the place where the model departs from the framework. **BR2.** The model assumes that every value the old screen writes in `bind` has been written to the screen; the placement of the label text, the font or truncation are not measured. What is measured is which keys the `bind` call writes values to.

In a real conversion the two screens are not shown to the same user at the same time; one leaves the stack, the other enters. In the model both exist, because the rule queries a field, not a screen, and for a rule that gives a result per field, putting the two side by side is enough.

The limit of the measurement is this: the rule does not compare the two screens with each other; it compares each of them separately with the model. There is no direct comparison between the old screen and the new screen; both are measured against the model. If the model itself is wrong, for example if a field is not defined in the model at all, both screens can carry the same gap and the rule finds both of them matching. The scope of the rule is only the known fields of the model.

When the conversion is finished, a last step remains: deleting the old screen. The leftover fields the old screen wrote go away with it. A field the new screen still leaves missing, on the other hand, finds no old screen to compare with from this point on, and the gap can be noticed only through the model. The guarantee incremental conversion gives is that, while the old screen is in place, every field gives the same result on both screens; this guarantee is lost unless the measurement is run after the old screen is deleted.

## Summary

- Legacy code is converted screen by screen; in the middle of the conversion two frameworks live in the same application and feed the same model.
- The `dueLabel` field not carried to the new screen is `tomorrow` in the model and does not exist on the screen at all: it is measured missing (2 matching, 1 missing).
- When the field is carried over, all three fields are matching (3 matching).
- When the old screen keeps writing the `badge` field dropped from the model, the result is leftover (1 matching, 1 leftover).
- When the same model is checked against two screens, the old screen is matching in three fields and the new screen in two; in total 5 matching, 1 missing. There is no single answer at screen level, the rule works per field.
- This lesson's agreement: **missing**.

## Next Step

The next lesson is the close of the topic and of the course: Choosing a Framework. We have measured the two frameworks with the same rule; from there we will look at the question of which one to choose and when.
