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
@Modelmacro, no Objective-C bridging. - SwiftUI integration —
@Queryin views,@Environment(\.modelContext)for writes. - Automatic migrations — Lightweight schema changes handled automatically.
- iCloud sync built-in — Via
ModelConfigurationwith 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
.xcdatamodeldand 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