Overview
The Core Data stack is the set of objects that connect your Swift model classes to a SQLite store on disk. Getting the setup right at the start prevents the most common production bugs: corrupt stores from missing merge policies, main-thread hangs from synchronous store loading, and untestable code from a global singleton that cannot be swapped. For concurrency rules on top of this stack, see core-data-concurrency; for schema evolution, see core-data-migrations.
Load the persistent store asynchronously
NSPersistentContainer.loadPersistentStores is synchronous by default but blocks until the store file is opened and any pending lightweight migration runs. For large stores or first-launch migrations, this can freeze the launch screen. Load on a background queue and gate the UI on completion.
final class PersistenceController {
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "Model")
if inMemory {
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}
configureViewContext()
}
func load() async throws {
try await withCheckedThrowingContinuation { continuation in
container.loadPersistentStores { _, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
private func configureViewContext() {
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}Call load() from the @main App struct’s .task modifier and show a launch screen until it resolves.
Set automaticallyMergesChangesFromParent and a merge policy on viewContext
Without automaticallyMergesChangesFromParent = true, background-context saves are invisible to the view context until a manual mergeChanges call. Without a merge policy, a conflict between a background write and a view-context read throws a NSMergeConflict at runtime.
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicyNSMergeByPropertyObjectTrumpMergePolicy lets the in-memory object win over the persistent store on conflict. For apps where the server is the authority, use NSMergeByPropertyStoreTrumpMergePolicy instead.
Inject the container via @Environment, not a global singleton
A singleton PersistenceController.shared is convenient but untestable and hard to replace with an in-memory store in previews and unit tests. Inject through the SwiftUI environment instead.
// App entry point
@main
struct MyApp: App {
@State private var persistence = PersistenceController()
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistence.container.viewContext)
.task { try? await persistence.load() }
}
}
}
// In a view
struct ItemList: View {
@Environment(\.managedObjectContext) private var context
// ...
}For tests and previews, create a PersistenceController(inMemory: true) and inject its viewContext instead. No production code needs to change.
Use an in-memory store for previews and tests
An in-memory store is identical to the on-disk store in behavior but discards data when the process exits. It is the right backing for #Preview macros and XCTest cases.
extension PersistenceController {
static var preview: PersistenceController = {
let controller = PersistenceController(inMemory: true)
controller.container.loadPersistentStores { _, _ in }
let ctx = controller.container.viewContext
for i in 0..<5 {
let item = Item(context: ctx)
item.title = "Sample \(i)"
item.createdAt = Date()
}
try? ctx.save()
return controller
}()
}Handle store load failures without silently discarding data
fatalError on store load is acceptable in development but hides real problems in production. Classify the failure before deciding: a corrupted store can be deleted and recreated; a migration failure on a store with irreplaceable user data requires a recovery path.
container.loadPersistentStores { description, error in
guard let error = error as NSError? else { return }
if error.domain == NSCocoaErrorDomain && error.code == NSPersistentStoreIncompatibleVersionHashError {
self.handleMigrationFailure(description: description)
} else {
fatalError("Unrecoverable Core Data error: \(error)")
}
}Log the error to your analytics pipeline. A store that fails to open silently is a support ticket waiting to happen.
Separate the container from view models with a repository layer
Views should not call NSFetchRequest directly, and view models should not own NSPersistentContainer. A repository protocol isolates persistence from swiftui views and makes the storage layer swappable.
protocol ItemRepository {
func items() throws -> [Item]
func add(title: String) throws -> Item
func delete(_ item: Item) throws
}
final class CoreDataItemRepository: ItemRepository {
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext) { self.context = context }
// ...
}Pass the protocol into view models. Tests provide a fake implementation; production wires the Core Data one.