Skip to content

Swift / SwiftUI — Persistence

OWNER: martijn, valentin ALSO_USED_BY: alexander (design integration) LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: Swift 6.1 / SwiftData (iOS 17+)


Overview

GE iOS apps use three persistence layers depending on data type and lifetime. Agents MUST choose the correct layer — using the wrong one causes data loss or performance issues.

Persistence Decision Tree

What kind of data?

User preferences / small flags (< 1KB per value)
  → UserDefaults

Structured domain data (orders, products, users)
  → SwiftData (new projects)
  → CoreData (existing projects only — no new CoreData adoption)

Sensitive credentials (tokens, passwords, keys)
  → Keychain (via Security framework)

Temporary cache (images, API responses)
  → URLCache (automatic) or FileManager (manual)

Large binary files (downloads, exports)
  → FileManager (Documents or Caches directory)

CHECK: Agent is persisting data. IF: Data is a user credential, API token, or secret. THEN: Use Keychain. NEVER UserDefaults, NEVER SwiftData, NEVER files.

SwiftData (Default for New Projects)

Why SwiftData

  • Swift-native — Uses @Model macro, no Objective-C bridging.
  • SwiftUI integration@Query in views, @Environment(\.modelContext) for writes.
  • Automatic migrations — Lightweight schema changes handled automatically.
  • iCloud sync built-in — Via ModelConfiguration with CloudKit container.

Model Definition

@Model
final class Order {
    var id: UUID
    var title: String
    var status: OrderStatus
    var createdAt: Date
    var updatedAt: Date

    @Relationship(deleteRule: .cascade)
    var lineItems: [LineItem]

    @Relationship(inverse: \Customer.orders)
    var customer: Customer?

    init(
        title: String,
        status: OrderStatus = .draft,
        customer: Customer? = nil
    ) {
        self.id = UUID()
        self.title = title
        self.status = status
        self.createdAt = .now
        self.updatedAt = .now
        self.lineItems = []
        self.customer = customer
    }

    enum OrderStatus: String, Codable {
        case draft, pending, active, completed, cancelled
    }
}

ANTI_PATTERN: Using @Attribute(.unique) on fields that may change. FIX: Only use @Attribute(.unique) on truly immutable identifiers (e.g., server-side UUID).

Container Setup

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Order.self, Customer.self, LineItem.self])
    }
}

CRUD Operations

// Read — in a View
struct OrderListView: View {
    @Query(sort: \Order.createdAt, order: .reverse)
    private var orders: [Order]

    var body: some View {
        List(orders) { order in
            OrderRow(order: order)
        }
    }
}

// Write — via ModelContext
struct CreateOrderView: View {
    @Environment(\.modelContext) private var context

    func createOrder(title: String) {
        let order = Order(title: title)
        context.insert(order)
        // SwiftData auto-saves on context changes
    }

    func deleteOrder(_ order: Order) {
        context.delete(order)
    }
}

Filtered Queries

@Query(
    filter: #Predicate<Order> { $0.status == .active },
    sort: \Order.createdAt
)
private var activeOrders: [Order]

CoreData (Existing Projects Only)

CHECK: Agent is starting a new iOS project. IF: Agent chooses CoreData for persistence. THEN: Reject. Use SwiftData for new projects. CoreData is only for maintaining existing codebases.

When CoreData is Acceptable

  • Legacy project with existing .xcdatamodeld and migration history.
  • Feature requires NSFetchedResultsController (no SwiftData equivalent yet).
  • Project targets iOS 16 or earlier (SwiftData requires iOS 17+).

CoreData Pitfalls in GE Context

ANTI_PATTERN: Accessing NSManagedObject properties off its context's queue. FIX: Always use perform or performAndWait on the context.

ANTI_PATTERN: Using a single viewContext for background imports. FIX: Create a background context with newBackgroundContext() for batch operations.

UserDefaults

When to Use

  • App settings (theme, language, onboarding completed).
  • Feature flags (local overrides).
  • Small, non-sensitive values that survive app restarts.
extension UserDefaults {
    private enum Keys {
        static let hasCompletedOnboarding = "hasCompletedOnboarding"
        static let preferredTheme = "preferredTheme"
    }

    var hasCompletedOnboarding: Bool {
        get { bool(forKey: Keys.hasCompletedOnboarding) }
        set { set(newValue, forKey: Keys.hasCompletedOnboarding) }
    }
}

SwiftUI Integration

struct SettingsView: View {
    @AppStorage("preferredTheme") private var theme: String = "system"

    var body: some View {
        Picker("Theme", selection: $theme) {
            Text("System").tag("system")
            Text("Light").tag("light")
            Text("Dark").tag("dark")
        }
    }
}

ANTI_PATTERN: Storing large data (images, arrays of 1000+ items) in UserDefaults. FIX: Use SwiftData or FileManager. UserDefaults is plist-backed — large values degrade startup time.

ANTI_PATTERN: Storing API tokens or passwords in UserDefaults. FIX: Use Keychain. UserDefaults is not encrypted and is readable from backups.

Keychain

Wrapper for GE Projects

enum KeychainService {
    static func save(key: String, data: Data) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data
        ]
        SecItemDelete(query as CFDictionary)
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.saveFailed(status)
        }
    }

    static func load(key: String) throws -> Data? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        guard status == errSecSuccess else {
            if status == errSecItemNotFound { return nil }
            throw KeychainError.loadFailed(status)
        }
        return result as? Data
    }

    static func delete(key: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]
        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.deleteFailed(status)
        }
    }
}

Server Sync Patterns

Offline-First Architecture

GE apps are offline-first when the client requires it. SwiftData stores the local copy. The API client syncs on connectivity.

@Observable
final class SyncManager {
    var lastSyncDate: Date?
    var isSyncing = false

    private let apiClient: APIClient
    private let context: ModelContext

    func sync() async throws {
        guard !isSyncing else { return }
        isSyncing = true
        defer { isSyncing = false }

        // 1. Push local changes
        let pendingChanges = try fetchPendingChanges()
        for change in pendingChanges {
            try await apiClient.push(change)
            change.markSynced()
        }

        // 2. Pull remote changes since last sync
        let remote = try await apiClient.fetchChanges(since: lastSyncDate)
        for item in remote {
            try upsertLocal(item)
        }

        lastSyncDate = .now
    }
}

CHECK: Agent is implementing sync. IF: Sync deletes local data before confirming server push succeeded. THEN: Fix the order. Push first, then pull, then clean up.

Conflict Resolution

GE default: server wins (last-write-wins). If the client requires conflict UI, use a ConflictResolver protocol and present choices.

GE-Specific Conventions

  • SwiftData for all new projects — CoreData only for legacy maintenance.
  • Never store secrets outside Keychain — includes tokens, passwords, API keys.
  • Offline-first when scoped — Aimee flags this during scoping if needed.
  • Server is SSOT — Local persistence is a cache with sync, not the source of truth.
  • Auto-save on — Do not call context.save() manually unless batch-importing.

Cross-References

READ_ALSO: wiki/docs/stack/swift-swiftui/index.md READ_ALSO: wiki/docs/stack/swift-swiftui/architecture.md READ_ALSO: wiki/docs/stack/swift-swiftui/networking.md READ_ALSO: wiki/docs/stack/swift-swiftui/pitfalls.md