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 = NSMergeByPropertyObjectTrumpMergePolicy

NSMergeByPropertyObjectTrumpMergePolicy 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.