top of page

Why use behaviour-preserving migration in our code?

Code from Next Rocket Launch • Full Xcode project ↗
struct EstimatedLaunchDate: Equatable, Sendable, Codable {
    let month: Int?
    let day: Int?
    let year: Int?
}

This small type comes from Next Rocket Launch. It can represent a launch date with a known month and year but no announced day. That uncertainty is part of the product’s behaviour: the app must not pretend to know an exact launch time when its data provider does not.

A migration can replace callbacks, reorganize features and improve names while preserving this meaning. “Modern code” is not a licence to invent missing information, redesign screens or change the data people rely on.

1. The approach

Code from Next Rocket Launch • Full Xcode project ↗
func testNullDayPreservesKnownMonthAndYear() throws {
    let page = try decode(["month": 9, "day": NSNull(), "year": 2026])
    XCTAssertNil(page[0].estimatedDate.day)
    XCTAssertEqual(page[0].estimatedDate.month, 9)
    XCTAssertEqual(page[0].estimatedDate.year, 2026)
}

The app’s regression test gives that promise a concrete form. A null day remains nil; the known month and year survive. The helper supplies a controlled response to the same decoder used by the provider.

Before changing implementation, identify outcomes like these and write down the contract. Keep representative stored files and API fixtures, record relevant UI behaviour, and separate requested changes from accidental differences. A refactor’s success is not just that it builds; the supported scenarios still mean the same thing afterward.

CFA’s documented rationale: migration and architecture adoption preserve established product behaviour and design unless the owner explicitly asks to change them. New-app defaults do not override the designer’s choices in an existing product.

2. The problem it solves

Code from Next Rocket Launch • Full Xcode project ↗
private struct LaunchPayload: Decodable {
    let id: Int
    let name: String
    let missions: [MissionPayload]
    let estimatedDate: DatePayload
    let provider: NamedPayload?
    let vehicle: NamedPayload?
    let pad: PadPayload?
    let t0: String?
    let sortDate: String?
    let slug: String?
    let dateLabel: String?
    let missionDescription: String?
    enum CodingKeys: String, CodingKey {
        case id, name, missions, provider, vehicle, pad, t0, slug
        case sortDate = "sort_date"
        case dateLabel = "date_str"
        case missionDescription = "mission_description"
        case estimatedDate = "est_date"
    }
}

The provider uses keys such as est_date and mission_description. Swift uses readable property names and maps them explicitly. A careless rename could compile perfectly and still stop decoding the service’s data if that mapping disappeared.

The same danger exists in UserDefaults keys, database fields, raw values and file paths. An internal source change can cross an external compatibility boundary without looking dramatic in a diff. Treat those strings and schemas as contracts, not clutter to tidy.

Do not silently “repair” uncertain data either. Replacing an unknown day with the first of the month might simplify a Date initializer, but it creates a new claim about the launch schedule. That would be a product change requiring an intentional decision.

3. Why it works

Code from Next Rocket Launch • Full Xcode project ↗
static func decodeResponse(_ data: Data) throws -> [RocketLaunch] {
    try JSONDecoder().decode(Response.self, from: data).result.map { payload in
        RocketLaunch(id: payload.id, name: payload.name,
                     missions: payload.missions.map { LaunchMission(name: $0.name, description: $0.description) },
                     estimatedDate: EstimatedLaunchDate(month: payload.estimatedDate.month,
                                                        day: payload.estimatedDate.day,
                                                        year: payload.estimatedDate.year),
                     details: LaunchDetails(provider: payload.provider?.name, vehicle: payload.vehicle?.name,
                        country: payload.pad?.location?.country, site: payload.pad?.location?.name,
                        plannedTime: try payload.t0.map(Self.parseLaunchTime),
                        sortTime: payload.sortDate.flatMap(Double.init).map { Date(timeIntervalSince1970: $0) },
                        estimatedDateLabel: payload.dateLabel, missionDescription: payload.missionDescription,
                        detailsURL: payload.slug.flatMap { launchWebURL("https://www.rocketlaunch.live/launch/" + $0) }))
    }
}

The mapping preserves the distinction between a precise planned time, a sorting timestamp and the provider’s date label. Transport can move behind an actor without requiring those concepts to collapse into one value.

Code from Next Rocket Launch • Full Xcode project ↗
var launchTime: String { detail.launchTime }
var timingNote: String { detail.timingNote }
func loadIfNeeded() {
    startObserving()
    guard refreshTask == nil else { return }
    startRefresh(onlyIfNeeded: true)
}

This display property follows the same distinction. It formats plannedTime when present; otherwise it uses the estimated label. Presentation stays understandable because the model did not throw away precision information during migration.

Swift’s Codable customization supports explicit mappings. It does not decide which old fields must be preserved for your users. That needs knowledge of the shipped app and fixtures that represent its real inputs.

4. Advantages of using it

Code from Next Rocket Launch • Full Xcode project ↗
func testInvalidDateTypeRemainsADecodingError() throws {
    XCTAssertThrowsError(try decode(["month": "invalid", "day": 1, "year": 2026])) { error in
        guard case DecodingError.typeMismatch = error else {
            return XCTFail("Expected type mismatch, got \(error)")
        }
    }
}

Preserving unknown information does not mean accepting arbitrary malformed information. This test expects the wrong JSON type to remain a decoding error. Together with the null-day test, it draws a useful boundary: absence can be valid; an invalid type is not silently converted into success.

Editorial trade-off: compatibility can retain behaviour you would design differently today. Record those deliberate limitations and propose a separate product change if needed. Do not combine a broad visual redesign, a storage migration and a concurrency rewrite simply because the files are already open.

After a migration, run the relevant tests against the real implementation and compare the actual UI journey, saved-data handling and failure states. A before-and-after match in these selected examples does not establish that every screen, permission flow or cancellation path is unchanged. The advantage of the contract is that it tells the team what evidence to gather.

Try this with the CFA AI Toolkit for iOS:

Using CFA for iOS, modernise this implementation while preserving the established product’s behaviour and design.

Scope: code from the linked app revisions; smaller excerpts belong to their containing types and targets. The articles distinguish older implementations from current CFA conventions. Source verification is not a full app build or device test. Suggested checks are guidance, not claims they ran against your application.

bottom of page