Adding support for versioning and migration to your Codable models.



Codable is a great protocol for automating simple model persistence, but it lacks support for any kind of versioning or migrating the data from older versions.

You can, of course, implement custom Codable adherence and throw in a bunch of if statements and manual decoding to achieve this goal, but isn’t that kind of killing the main selling point of Codable?

Let’s look at an idea that adds Versoning yet still leverages derived Codable.


Requirements

  • We want to be able to leverage automatically derived Codable implementation even as our models change over time.

  • If we want to persist/decode our models without using versioning support we should be able to (leveraging pure Codable implementation).

  • Migrations should be pure functions localized to specific Models and require minimal work to be added.

Design

First off Versionable protocol:

public protocol VersionType: CaseIterable, Codable, Comparable, RawRepresentable {}
public protocol Versionable: Codable {
    associatedtype Version: VersionType

    static func migrate(to: Version) -> Migration
    static var version: Version { get }

    /// Persisted Version of this type
    var version: Version { get }
}

You need to provide enumeration for all available model versions + a function that can migrate to each of them.

An example of how you’d adhere to this protocol is the following:

private struct Complex {
    let text: String
    let number: Int
    var version: Version = Self.version
}

extension Complex: Versionable {
    enum Version: Int, VersionType {
        case v1 = 1
        case v2 = 2
        case v3 = 3
    }

    static func migrate(to: Version) -> Migration {
        switch to {
        case .v1:
            return .none
        case .v2:
            return .migrate {  payload in
                payload["text"] = "defaultText"
            }
        case .v3:
            return .migrate { payload in
                payload["number"] = (payload["text"] as? String) == "defaultText" ? 1 : 200
            }
        }
    }
}

Migrations are pure functions (closures) that modify the JSON Dictionary before the actual decoding, you can add default values or derive values based on previously available data.

Now to decode this model we provide a custom VersionableDecoder that has a single method that implements all our migration logic:

func decode<T>(type: T.Type, from data: Data, usingDecoder decoder: JSONDecoder = .init()) throws -> T where T: Versionable

The way this function works is the following:

  1. First checks if the persisted model has the same version as the newest model our app has if so we simply use provided JSONDecoder
  2. If our persisted version doesn’t match the current model version we filter out all migrations that apply to that route
  3. We decode the data payload into a native dictionary and route it through all migrations one by one
  4. We run updated payload through provided JSONDecoder()

The whole function is simply:

public func decode<T>(_ type: T.Type, from data: Data, usingDecoder decoder: JSONDecoder = .init()) throws -> T where T: Versionable {
    let serializedVersion = try decoder.decode(VersionContainer<T.Version>.self, from: data)

    if serializedVersion.version == type.version {
        return try decoder.decode(T.self, from: data)
    }

    var payload = try require(try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any])

    type
        .Version
        .allCases
        .filter { serializedVersion.version < $0 }
        .forEach {
            type.migrate(to: $0)(&payload)
            payload["version"] = $0.rawValue
        }

    let data = try JSONSerialization.data(withJSONObject: payload as Any, options: [])
    return try decoder.decode(T.self, from: data)
}

We can now decode our models while applying all available migrations by doing:

let model = VersionableDecoder().decode(data, type: Object.self)

Performance consideration

If you want to use something like this in production, I’d suggest changing the decoding algorithm slightly to avoid using both JSONDecoder and JSONSerialization.

That can be done by using a decoder that allows you to create Codable from native dictionary directly rather than through Data like https://github.com/elegantchaos/DictionaryCoding

You can find all source code and few tests for this prototype on my GitHub repo https://github.com/krzysztofzablocki/Versionable

Special Thanks

I’ve updated the article to use Enum as a VersionType rather than simple Integer. This idea came from Manuel Maly 🙇🙇🙇.

This approach means that if you add a new version of the model the compiler will warn you if you forgot to add migration, compile level errors are always best safety net for human errors.


If you found this article interesting, consider retweeting it. I'm also interested in your thoughts and suggestions for improving it.