Safely migrating persisted models in iOS to prevent crashes

Most mobile native apps (iOS/Android) support evolving businesses, and must be ready to accommodate changes. Evolving persisted data in an app is crucial for maintaining a good user experience and preventing obsolescence as business requirements change. In my personal experience with production crashes, this is one of the most common issues encountered after a production release. While removing and reinstalling the app often fixes the issue, this is not an ideal solution.

The aim of this post is to demonstrate a possible method for handling migration in non-database persisted data. We will explain how to migrate JSON stored in user defaults, though this approach could also apply to data stored in files or keychain. It's important to note that we're focusing on non-database storage because database frameworks typically provide their own migration mechanisms that should be followed.

The starting point

The base application used as the foundation for this post simply displays a button. When pressed, it saves a structure to UserDefaults. Upon restarting the app after it has been closed, the previously stored content is retrieved from UserDefaults.

Let me introduce the persisted structure. It has been simplified for better understanding, but is not what you would find in a real production app:

struct Person: Codable {
    let name: String
    let age: String
}

New requirements

The business has decided that an email address is also required. Therefore, we will proceed to implement this feature.

struct Person: Codable {
    let name: String
    let age: String
    let email: String
}
Build and run, but suddenly something unexpected happens…

Initially, the app stored the data in the format {name, age}. However, it has since been updated to expect the format {name, age, email}. This discrepancy means that previously persisted data cannot be decoded properly, leading to an exception being thrown.

A common mistake during development at this stage is to simply remove the app from the simulator, reinstall the new version, and move on, effectively ignoring the issue. This is a poor decision, as it fails to address the root problem. Eventually, this oversight will come back to haunt you when the same issue occurs on hundreds, thousands, or an unacceptably large number of real app installations. This will result in a very poor user experience.

The first step to properly address this issue is to add a version field to the data structure. This allows for better handling of future changes to the structure.

struct Person: Codable {
    var version: Int {
        return 1
    }
    let name: String
    let age: String
    let email: String
}

We will implement a Migration Manager responsible for handling migrations

@MainActor
protocol MigrationManagerProtocol {
    func applyMigration()
}

@MainActor
final class MigrationManager: ObservableObject {
    @Published var migrationPenging = true
    @Published var migrationFailed = false
    
    struct PersonV0: Codable {
        let name: String
        let age: String
    }
    
    typealias PersonV1 = Person
}

In this class, we will include a copy of the original Person structure, referred to as PersonV0. The current Person structure is named PersonV1

extension MigrationManager: MigrationManagerProtocol {
    func applyMigration() {
        defer { migrationPenging = false }
        applyPersonMigration()
    }
    
    private func isPersonMigrationPending() -> Bool {
        let userDefaultsManager = appSingletons.userDefaultsManager
        return userDefaultsManager.get(Person.self, forKey: UserDefaultsManager.key.person) == nil
    }
    
    private func applyPersonMigration() {
        let userDefaultsManager = appSingletons.userDefaultsManager

        guard isPersonMigrationPending() else {
            return // No migration needed
        }
        let currentStoredPersonVersion = storedPersonVersion()
        if currentStoredPersonVersion == 0,
            let personV0 = userDefaultsManager.get(PersonV0.self, forKey: UserDefaultsManager.key.person) {
            let person = PersonV1(name: personV0.name, age: personV0.age, email: "---")
            saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
        }
    }
    
    private func storedPersonVersion() -> Int {
            return 0
    }
...
}

The migration process begins by determining the version of the Person structure stored using the storedPersonVersion() function. At this stage in the application’s evolution, the version is 0.

If the current stored version is 0, the process involves fetching PersonV0 from UserDefaults and performing a migration. This migration entails transforming PersonV0 into PersonV1 by adding an email field with a default value.

struct JSONPersistedMigrationApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onAppear {
#if DEBUG
                    setupDebugSwift()
#endif
                }
                .onShake {
#if DEBUG
                    DebugSwift.show()
#endif
                }
                .task {
                    appSingletons.migrationManager.applyMigration()
                }
        }
    }

Finally, we call migrationManager.applyMigration() within the .task modifier to ensure it executes only once during the app’s startup.

For debugging purposes, I found the DebugSwift tool very useful. I explain in more detail how to integrate this tool and its main features in the following post.

Now, build and run the app:

Migration is currently being executed, and the app is displaying data properly.

When we open the DebugView tool to review user defaults, we observe that the migration has been completed exactly as expected.

Did we really finish? Well, not yet. It is mandatory to implement a test case that ensures the Person object can be migrated from V0 to V1.

Updating an attribute

Once the infrastructure for running migrations is ready, the next changes to the persisted structure should involve either a Tic-Tac-Toe game or an easy recipe.

Now, the business has requested that we rename the «name» field to «alias.» The first step is to update the structure by increasing the version number and renaming the field.

struct Person: Codable {
    var version: Int {
        return 2
    }
    
    let alias: String
    let age: String
    let email: String
}

Second, add PersonV1 to the Migration Manager and set PersonV2 as the current Person structure.

@MainActor
final class MigrationManager: ObservableObject {
    @Published var migrationPenging = true
    @Published var migrationFailed = false
    
    struct PersonV0: Codable {
        let name: String
        let age: String
    }
    
    struct PersonV1: Codable {
        var version: Int {
            return 1
        }
        let name: String
        let age: String
        let email: String
    }
    
    typealias PersonV2 = Person
}

The third step is to update storedPersonVersion(). This time, the stored Person version could be either V0 or V1.

    private func storedPersonVersion() -> Int {
        let userDefaultsManager = appSingletons.userDefaultsManager
        if let personV1 = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
            return 1
        } else {
            return 0
        }
    }

The fourth step is to implement the migration-if-block inside applyPersonMigration.

    private func applyPersonMigration() {
        let userDefaultsManager = appSingletons.userDefaultsManager

        guard isPersonMigrationPending() else {
            return // No migration needed
        }
        let currentStoredPersonVersion = storedPersonVersion()
        if currentStoredPersonVersion == 0,
            let personV0 = userDefaultsManager.get(PersonV0.self, forKey: UserDefaultsManager.key.person) {
            let person = PersonV1(name: personV0.name, age: personV0.age, email: "---")
            saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
        }
        if currentStoredPersonVersion <= 1,
            let personV1 = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
            let person = PersonV2(alias: personV1.name, age: personV1.age, email: personV1.email)
            saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
        }
    }

It has to be an independent if-block,. In case the app were V0 both if-blocks would be executed, and in case were V1 the tha last block woud be executed.

Fifth and last step, do unit test, now appears a new test case. One for testing migration from V0 to V2 and another for V1 to V2:

Remember, five steps one after the other.

Removing an attribute

Now, we will remove the age attribute. One, update the person structure.

struct Person: Codable {
    var version: Int {
        return 3
    }
    
    let alias: String
    let email: String
}

Two, add PersonV2 to MigrationManager and set PersonV3 as the current Person structure.

@MainActor
final class MigrationManager: ObservableObject {
    @Published var migrationPenging = true
    @Published var migrationFailed = false
    
    struct PersonV0: Codable {
        let name: String
        let age: String
    }
    
    struct PersonV1: Codable {
        var version: Int {
            return 1
        }
        
        let name: String
        let age: String
        let email: String
    }
    
    struct PersonV2: Codable {
        var version: Int {
            return 2
        }
        
        let alias: String
        let age: String
        let email: String
    }
    
    typealias PersonV3 = Person
}

Three, update storedPersonVersion(). This time, the stored Person version could be V0, V1, or V2:

    private func storedPersonVersion() -> Int {
        let userDefaultsManager = appSingletons.userDefaultsManager
        if let _ = userDefaultsManager.get(PersonV2.self, forKey: UserDefaultsManager.key.person) {
            return 2
        } else if let _ = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
            return 1
        } else {
            return 0
        }
    }

Four, the migration-if-block inside applyPersonMigration:

private func applyPersonMigration() {
        let userDefaultsManager = appSingletons.userDefaultsManager

        guard isPersonMigrationPending() else {
            return // No migration needed
        }
        let currentStoredPersonVersion = storedPersonVersion()
        if currentStoredPersonVersion == 0,
            let personV0 = userDefaultsManager.get(PersonV0.self, forKey: UserDefaultsManager.key.person) {
            let person = PersonV1(name: personV0.name, age: personV0.age, email: "---")
            saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
        }
        if currentStoredPersonVersion <= 1,
            let personV1 = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
            let person = PersonV2(alias: personV1.name, age: personV1.age, email: personV1.email)
            saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
        }
        if currentStoredPersonVersion <= 2,
            let personV2 = userDefaultsManager.get(PersonV2.self, forKey: UserDefaultsManager.key.person) {
            let person = PersonV3(alias: personV2.alias, email: personV2.email)
            saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
        }
    }

Five: Perform unit tests. A new test case has now been added, which tests migration from V0 to V3, V1 to V3, and V2 to V3.

Conclusions

In this post, you learned how to migrate non-database persisted data in your app. You can find the base project for developing this post in this repository.

Copyright © 2024-2025 JaviOS. All rights reserved