# Saved Templates That Survive App Updates: Versioned Codable in Swift

The first version of a "save this as a template" feature is always easy. Make the struct `Codable`, encode an array to JSON, write it to disk. Ship it.

The trouble arrives in the second version, when you add a field, rename one, or change what a number means. Now there are files on people's phones written by the old code, and the new code has to read them. If decoding throws, the friendliest outcome is that someone opens the app and their saved templates are gone. Nobody files a bug report for that. They just stop trusting the feature.

I build BatchMark, a batch photo app for iPhone, where people save a watermark setup once and reapply it later (the [iPhone watermarking guide](https://batchmark.dispatchcat.com/guides/watermark-photos-on-iphone/?utm_source=hashnode&utm_medium=article&utm_campaign=round6) shows the user-facing side). This post is about the unglamorous part underneath: saving user templates in Swift so that next year's app still opens this year's files. The model below is a simplified teaching version, not the app's actual schema.

## Start with a version number you don't need yet

```swift
import Foundation

struct WatermarkTemplate: Identifiable, Equatable {
    static let currentSchemaVersion = 2

    var id: UUID
    var name: String
    var text: String
    var fontName: String
    /// Text height as a fraction of the photo's shorter side.
    var relativeSize: Double
    var opacity: Double
    var anchor: Anchor
    /// Inset from the edges as a fraction of the photo's shorter side. Added in v2.
    var margin: Double

    enum Anchor: String, CaseIterable {
        case topLeading, top, topTrailing
        case center
        case bottomLeading, bottom, bottomTrailing
    }
}
```

The struct isn't `Codable` yet on purpose. Synthesized conformance is great until the day it silently encodes the new shape and refuses to decode the old one. For anything a user saved by hand, I prefer to write the coding by hand once and keep control.

`schemaVersion` isn't stored on the struct. It is written into every encoded template and read back to decide how to interpret the rest. Version 1 was the first shipped format; version 2 is the one in this post.

## Write the decoder for every version you shipped

Imagine version 1 stored the text size as `pointSize`, meaning points on a 1080-pixel-wide reference canvas, and had no `margin` at all. Version 2 stores `relativeSize` and `margin`. The decoder handles both:

```swift
extension WatermarkTemplate: Codable {
    private enum Keys: String, CodingKey {
        case schemaVersion
        case id, name, text, fontName, relativeSize, opacity, anchor, margin
        case legacyPointSize = "pointSize"
    }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: Keys.self)
        let version = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1

        id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
        name = try c.decode(String.self, forKey: .name)
        text = try c.decode(String.self, forKey: .text)
        fontName = try c.decodeIfPresent(String.self, forKey: .fontName) ?? "System"
        opacity = try c.decodeIfPresent(Double.self, forKey: .opacity) ?? 0.8

        if version >= 2 {
            relativeSize = try c.decode(Double.self, forKey: .relativeSize)
        } else {
            let points = try c.decodeIfPresent(Double.self, forKey: .legacyPointSize) ?? 48
            relativeSize = Self.relativeSize(fromLegacyPoints: points)
        }

        let rawAnchor = try c.decodeIfPresent(String.self, forKey: .anchor)
        anchor = rawAnchor.flatMap(Anchor.init(rawValue:)) ?? .bottomTrailing

        margin = try c.decodeIfPresent(Double.self, forKey: .margin) ?? 0.04

        // Clamp anything a hand-edited or buggy file could push out of range.
        opacity = min(max(opacity, 0.05), 1)
        relativeSize = min(max(relativeSize, 0.005), 0.5)
    }

    func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: Keys.self)
        try c.encode(Self.currentSchemaVersion, forKey: .schemaVersion)
        try c.encode(id, forKey: .id)
        try c.encode(name, forKey: .name)
        try c.encode(text, forKey: .text)
        try c.encode(fontName, forKey: .fontName)
        try c.encode(relativeSize, forKey: .relativeSize)
        try c.encode(opacity, forKey: .opacity)
        try c.encode(anchor.rawValue, forKey: .anchor)
        try c.encode(margin, forKey: .margin)
    }

    static func relativeSize(fromLegacyPoints points: Double) -> Double {
        points / 1080
    }
}
```

Putting the `Codable` conformance in an extension keeps the memberwise initializer, which is handy for built-in presets and tests.

The rules this decoder follows:

- **Missing version means version 1.** The first release didn't write one. Every later release does.
- **New fields use `decodeIfPresent` with a sensible default.** Older files simply don't have `margin`. That isn't an error.
- **Changed meanings are converted, not reinterpreted.** A v1 `pointSize` of 48 is not a v2 `relativeSize` of 48. The conversion lives in one named function so it's testable.
- **Enums decode from a raw string with a fallback.** If a future version adds `.diagonal` and someone downgrades, an unknown anchor becomes the default instead of throwing away the template.
- **Values are clamped.** A template with opacity `0` is a template that makes invisible watermarks. Clamp at the boundary rather than trusting the file.
- **The encoder always writes the current version.** Once a v1 template is loaded and saved again, it is migrated for good.

## One bad template shouldn't take the rest down

The decoder above is tolerant, but some files will still be broken: truncated writes, a bug in a TestFlight build, a field that is simply the wrong type. If the whole file is decoded as `[WatermarkTemplate]`, one bad element throws and you lose all of them.

Decode the array element by element instead, skipping what fails:

```swift
struct TemplateFile: Codable {
    var templates: [WatermarkTemplate]

    init(templates: [WatermarkTemplate]) {
        self.templates = templates
    }

    private enum Keys: String, CodingKey { case templates }

    /// Decodes anything, so a failed element can be stepped over.
    private struct Skip: Decodable {
        init(from decoder: Decoder) throws {}
    }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: Keys.self)
        var list = try c.nestedUnkeyedContainer(forKey: .templates)
        var decoded: [WatermarkTemplate] = []
        while !list.isAtEnd {
            if let template = try? list.decode(WatermarkTemplate.self) {
                decoded.append(template)
            } else {
                _ = try? list.decode(Skip.self)
            }
        }
        templates = decoded
    }

    func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: Keys.self)
        try c.encode(templates, forKey: .templates)
    }
}
```

The `Skip` type matters. An unkeyed container only moves to the next element after a successful decode. Without something that always succeeds, a single bad element would leave you looping on it forever.

## Save atomically, and never overwrite what you couldn't read

```swift
struct TemplateStore {
    let fileURL: URL

    static func standard() throws -> TemplateStore {
        let support = try FileManager.default.url(
            for: .applicationSupportDirectory,
            in: .userDomainMask,
            appropriateFor: nil,
            create: true
        )
        return TemplateStore(fileURL: support.appendingPathComponent("templates.json"))
    }

    func load() -> [WatermarkTemplate] {
        guard let data = try? Data(contentsOf: fileURL) else { return [] }
        do {
            return try JSONDecoder().decode(TemplateFile.self, from: data).templates
        } catch {
            keepUnreadableCopy()
            return []
        }
    }

    func save(_ templates: [WatermarkTemplate]) throws {
        let encoder = JSONEncoder()
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        let data = try encoder.encode(TemplateFile(templates: templates))
        try data.write(to: fileURL, options: .atomic)
    }

    private func keepUnreadableCopy() {
        let stamp = Int(Date().timeIntervalSince1970)
        let backup = fileURL
            .deletingPathExtension()
            .appendingPathExtension("unreadable-\(stamp).json")
        try? FileManager.default.copyItem(at: fileURL, to: backup)
    }
}
```

Two habits here:

- **`.atomic` writes.** The data goes to a temporary file and is then swapped in. If the app is killed mid-write, the old file survives intact instead of being half a JSON document.
- **Back up what you couldn't read before anything can overwrite it.** If the top-level decode fails and you return an empty list, the next save would replace the user's file with `[]`. Copying it aside first means a later version (or you, with a support email) can still recover it.

Application Support is the right home: it is backed up, it isn't visible in the Files app, and it isn't purged like caches.

## Keep built-in presets out of the user's file

Built-in presets are tempting to store alongside user templates. Don't. If presets live in the same file, you can never improve a preset without either migrating every copy or leaving people stuck with the old one.

Keep presets in code with stable IDs, and only write user-made templates to disk:

```swift
extension WatermarkTemplate {
    static let builtIn: [WatermarkTemplate] = [
        WatermarkTemplate(
            id: UUID(uuidString: "6F1C2B8E-7A55-4C1E-9E2B-1D0B6E3A0001")!,
            name: "Minimal corner",
            text: "Your Name",
            fontName: "System",
            relativeSize: 0.025,
            opacity: 0.8,
            anchor: .bottomTrailing,
            margin: 0.04
        )
    ]
}
```

When someone edits a preset, save the edit as a new user template with a fresh ID. The preset stays a preset; their version is theirs.

## Test old files with fixtures, not hopes

The only way to know version 3 can read version 1 is to keep a version 1 file in your test bundle and decode it on every run.

```swift
import XCTest

final class TemplateMigrationTests: XCTestCase {
    func testDecodesVersion1File() throws {
        let json = """
        {
          "templates": [
            { "name": "Shop", "text": "@myshop", "pointSize": 54, "anchor": "bottomTrailing" },
            { "name": "Broken", "text": 42 },
            { "name": "Future", "text": "hi", "schemaVersion": 2,
              "relativeSize": 0.03, "anchor": "diagonal" }
          ]
        }
        """
        let file = try JSONDecoder().decode(TemplateFile.self, from: Data(json.utf8))

        XCTAssertEqual(file.templates.count, 2)
        XCTAssertEqual(file.templates[0].relativeSize, 54.0 / 1080.0, accuracy: 0.0001)
        XCTAssertEqual(file.templates[0].margin, 0.04)
        XCTAssertEqual(file.templates[1].anchor, .bottomTrailing)
    }
}
```

That one test covers a missing version, a renamed field, a missing field, a malformed element that must be skipped, and an unknown enum value. When you ship version 3, copy a real version 2 file into the fixtures before you change anything.

## The checklist

- Write a schema version into every saved object, starting now.
- Decode by hand for anything users created; default new fields; convert changed ones.
- Decode arrays leniently so one bad item doesn't erase the rest.
- Write atomically; back up unreadable files before you can overwrite them.
- Keep built-in presets in code, not in the user's file.
- Keep a fixture for every version you've shipped.

I'm Maxwell, and I learned most of this list the slow way. Saved templates look like a small feature, but they're the one place where people trust your app to remember something for them, and that's worth a little extra code.

