Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 

Repository files navigation

CoreDataManager

A production-ready, thread-safe CoreData singleton for iOS and macOS. Drop in one file, get full CRUD, batch operations, async/await, Combine, and NSFetchedResultsController support — with zero third-party dependencies.


Requirements

Minimum
iOS 13.0+
macOS 10.15+
Swift 5.10+ (required for nonisolated(unsafe), SE-0412) — 6.0+ if building in Swift 6 language mode
Xcode 15.3+ — 16+ if building in Swift 6 language mode
Dependencies None

The core CRUD API itself only ever needed iOS 13 / Swift 5.7. The floor above is higher only because of the Swift 6 concurrency annotations added to the file (nonisolated(unsafe), @unchecked Sendable, @preconcurrency import, @Sendable closures). If you strip those out and go back to a plain lazy var singleton, the original iOS 13 / Swift 5.7 / Xcode 14 floor applies — but you'd be reintroducing the lazy var race condition described below.


Installation

  1. Copy CoreDataManager.swift into your Xcode project.
  2. Ensure your .xcdatamodeld file is added to the target.
  3. Open CoreDataManager.swift and update the model name if needed:
private let modelName = "Model" // ← match your .xcdatamodeld filename

That's it. No SPM package, no CocoaPods, no setup boilerplate.


Swift 6 / Strict Concurrency

This file compiles cleanly under Swift 6 language mode. What changed to make that possible:

Change Why
@preconcurrency import CoreData Suppresses non-Sendable warnings from CoreData types on SDKs that predate their Sendable annotations.
final class CoreDataManager: @unchecked Sendable The type's state is either immutable after init or delegates thread-safety to NSManagedObjectContext's own serial queue.
nonisolated(unsafe) static let shared static let init is race-free by language guarantee (dispatch-once semantics); paired with @unchecked Sendable above.
persistentContainer and storeChangedPublisher are eager let, not lazy var lazy var initialization is not thread-safe against concurrent first access — this was a real bug independent of Swift 6, which strict concurrency happens to surface.
@Sendable on closures passed to performBackgroundTask and performAndSaveAsync These closures run on the background context's private queue — a genuine isolation-domain crossing that the compiler must verify.

The one rule that doesn't change with any of this: NSManagedObject is not Sendable and never will be — it's a mutable object tied to the context that created it. Never pass an NSManagedObject across a performBackgroundTask / async boundary. Pass its NSManagedObjectID (which is Sendable) instead, and re-fetch on the other side:

let objectID = user.objectID

manager.performBackgroundTask { ctx in
    guard let user = try? ctx.existingObject(with: objectID) as? User else { return }
    user.name = "Updated"
}

If your project is still on Swift 5 mode, all of the above is inert — @unchecked Sendable and nonisolated(unsafe) simply have no effect, and the code behaves exactly as before.


Quick Start

let manager = CoreDataManager.shared

// Create
manager.create(entityName: "User") { obj in
    obj.setValue(UUID(), forKey: "id")
    obj.setValue("Bhargav", forKey: "name")
}
manager.save()

// Read
let users = manager.fetch("User")

// Update
manager.update("User",
               predicate: NSPredicate(format: "name == %@", "Bhargav")) { obj in
    obj.setValue("Bhargav S.", forKey: "name")
}

// Delete
manager.deleteAll("User",
                  predicate: NSPredicate(format: "name == %@", "Bhargav S."))

Architecture

Contexts

CoreDataManager exposes two context tiers:

viewContext — runs on the main thread. Use for all UI reads and light user-driven writes.

newBackgroundContext() — returns a private-queue context. Use for imports, migrations, or any work that would block the UI.

Both contexts share NSMergeByPropertyObjectTrumpMergePolicy and the view context automatically merges changes pushed from background saves.


API Reference

Save

// Save view context
manager.save() -> Bool

// Save any context
manager.save(context: ctx) -> Bool

// Run block on background context, save automatically, callback on main thread
manager.performBackgroundTask({ ctx in
    // do work
}) { success in
    print("Saved:", success)
}

// async/await (iOS 15+)
try await manager.saveAsync()
try await manager.performAndSaveAsync { ctx in
    // do work
}

Create

// Untyped — returns NSManagedObject?
manager.create(entityName: "User") { obj in
    obj.setValue("Bhargav", forKey: "name")
}

// Typed generic — returns your NSManagedObject subclass
manager.createObject(of: User.self) { user in
    user.name = "Bhargav"
}

// In a specific context
manager.createObject(of: User.self, context: backgroundCtx) { user in
    user.name = "Bhargav"
}

Fetch

// Basic fetch
let all = manager.fetch("User")

// With predicate and sort
let sorted = manager.fetch(
    "User",
    predicate: NSPredicate(format: "age > %d", 18),
    sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)]
)

// Typed fetch
let users: [User] = manager.fetchObjects(of: User.self)

// Fetch by NSManagedObjectID
let obj = manager.fetchObject(withID: someObjectID)

// Count without loading objects
let total = manager.count("User")
let exists = manager.exists("User", predicate: NSPredicate(format: "name == %@", "Bhargav"))

// async/await (iOS 15+)
let users = await manager.fetchAsync(
    of: User.self,
    predicate: NSPredicate(format: "active == true"),
    sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)]
)

Update

// In-memory update (loads objects into context)
manager.update("User",
               predicate: NSPredicate(format: "active == false")) { obj in
    obj.setValue(true, forKey: "active")
}

// Batch update — store-level, never loads objects (fast for large datasets)
manager.batchUpdate(
    "User",
    propertiesToUpdate: ["active": true],
    predicate: NSPredicate(format: "lastSeen < %@", cutoffDate as CVarArg)
)

When to use batch: Updating hundreds or thousands of rows. Changes are written directly to the SQLite store and merged back into the view context automatically.


Delete

// Single object
manager.delete(someObject)

// All matching objects
manager.deleteAll("User")
manager.deleteAll("User", predicate: NSPredicate(format: "active == false"))

// Batch delete — store-level, tombstones merged into view context automatically
manager.batchDelete("User")
manager.batchDelete("User", predicate: NSPredicate(format: "createdAt < %@", cutoffDate as CVarArg))

Upsert

Find-or-create in a single call. Inserts if not found, updates if found.

manager.upsert(
    "User",
    predicate: NSPredicate(format: "id == %@", userId as CVarArg)
) { obj in
    obj.setValue(userId, forKey: "id")
    obj.setValue("Bhargav", forKey: "name")
}

NSFetchedResultsController

For driving UITableView / UICollectionView:

let frc = manager.makeFetchedResultsController(
    entityName: "User",
    predicate: NSPredicate(format: "active == true"),
    sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)],
    sectionNameKeyPath: nil,
    cacheName: "UserCache"
)
frc.delegate = self
try? frc.performFetch()

Combine

Subscribe to any save event (main or background context):

manager.storeChangedPublisher
    .sink { [weak self] in
        self?.tableView.reloadData()
    }
    .store(in: &cancellables)

Fires on the main thread every time any context is saved.


Store Maintenance

// Discard all unsaved changes in the view context
manager.resetContext()

// Wipe and rebuild the persistent store (logout / data reset flows)
// ⚠️ Irreversible — all data is permanently deleted
manager.destroyAndRebuildStore()

Debug Helpers

Available only in DEBUG builds (#if DEBUG), stripped from release:

// Print all attribute values of an object
manager.debugPrint(someObject)

// Path to the SQLite file on disk
print(manager.storeFilePath ?? "No store found")

Thread Safety

What Where
UI reads and light writes viewContext (main thread)
Imports, bulk writes performBackgroundTask or performAndSaveAsync
Large dataset updates batchUpdate / batchDelete — safe from any thread
Reactive UI updates storeChangedPublisher

@MainActor on viewContext-only methods

save(), create(entityName:configure:), createObject(of:configure:), fetch(...), fetchObjects(of:...), fetchObject(withID:), count(...), exists(...), update(...), deleteAll(...), upsert(...), resetContext(), and makeFetchedResultsController(...) are all marked @MainActor, since they run directly on viewContext with no perform wrapper. Calling them from ordinary UI code (a SwiftUI view, a UIViewController) needs no changes — those are @MainActor-isolated by default already. Calling them from a plain background Task or a non-main actor now requires await:

// From a UIViewController / SwiftUI view — no change needed:
let users = manager.fetch("User")

// From a detached background Task — now requires await:
Task.detached {
    let users = await manager.fetch("User") // hops to the main actor
}

For genuinely background-thread work, use the context:-explicit overloads instead (fetch(_:context:), create(entityName:context:configure:), etc.), which stay nonisolated and are safe to call from any thread you're already confined to (e.g. inside performBackgroundTask), or use batchUpdate/batchDelete/fetchAsync/performAndSaveAsync, which handle the threading internally.

Never pass NSManagedObject instances across contexts. Use NSManagedObjectID instead:

let objectID = user.objectID

manager.performBackgroundTask { ctx in
    guard let user = try? ctx.existingObject(with: objectID) as? User else { return }
    user.name = "Updated"
}

Remote & Cross-Process Sync

The container has NSPersistentHistoryTrackingKey and NSPersistentStoreRemoteChangeNotificationPostOptionKey enabled. When another writer (an App Extension, another process, or NSPersistentCloudKitContainer) changes the store, NSPersistentStoreCoordinator posts .NSPersistentStoreRemoteChange, which handleRemoteStoreChange(_:) observes.

That notification only carries a store UUID and history token — it does not carry the changed objects, so it can't be merged directly. Instead, handleRemoteStoreChange(_:) fetches everything that happened since the last known token via NSPersistentHistoryChangeRequest, merges each resulting transaction into viewContext, and advances the token. The token itself is persisted in UserDefaults (key: CoreDataManager.lastHistoryToken) so a relaunch picks up exactly where it left off rather than re-merging the entire history.

To opt into CloudKit sync, swap NSPersistentContainer for NSPersistentCloudKitContainer in the persistentContainer property and add the CloudKit capability in Xcode.


File Structure

CoreDataManager.swift
├── Singleton & configuration
├── NSPersistentContainer setup
├── Context management (viewContext, newBackgroundContext)
├── Combine publisher
├── Save (sync + async + background)
├── Create (untyped + generic)
├── Fetch (predicate, sort, count, exists, async)
├── Update (in-memory + batch)
├── Delete (single, bulk, batch)
├── Upsert
├── NSFetchedResultsController factory
├── Store maintenance (reset, destroy)
├── Remote change notifications
└── Debug helpers (#if DEBUG)

License

MIT — free to use in personal and commercial projects.

About

A production-ready, thread-safe CoreData singleton for iOS and macOS. Features full CRUD support, batch operations, Combine, async/await, and strict concurrency readiness (Swift 6).

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages