A production-ready, thread-safe SwiftData singleton for iOS 17+ / macOS 14+.
Zero third-party dependencies. Full CRUD surface with async/await, Combine, batch operations, background tasks, and transactions.
| Requirement | Version |
|---|---|
| iOS / iPadOS | 17.0+ |
| macOS | 14.0+ |
| Xcode | 15.0+ |
| Swift | 5.9+ |
Copy SwiftDataManager.swift into your Xcode project. No Package.swift or SPM entry needed.
import SwiftData
@Model
final class Note {
var id: UUID
var title: String
var body: String
var createdAt: Date
init(title: String, body: String) {
self.id = UUID()
self.title = title
self.body = body
self.createdAt = Date()
}
}import SwiftUI
import SwiftData
@main
struct MyApp: App {
init() {
let schema = Schema([Note.self])
try! SwiftDataManager.shared.configure(schema: schema)
// For in-memory (unit tests / Previews):
// try! SwiftDataManager.shared.configure(
// schema: schema,
// configuration: ModelConfiguration(isStoredInMemoryOnly: true)
// )
}
var body: some Scene {
WindowGroup { ContentView() }
}
}let manager = SwiftDataManager.shared
// Create
let note = Note(title: "Hello", body: "World")
try manager.insert(note)
// Read all
let notes: [Note] = try manager.fetch(Note.self)
// Read with filter + sort
let filtered = try manager.fetch(
Note.self,
predicate: #Predicate { $0.title.contains("Hello") },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
// Update
try manager.update(note) { $0.title = "Updated title" }
// Delete
try manager.delete(note)// Persistent (default)
func configure(schema: Schema, configuration: ModelConfiguration = ModelConfiguration()) throws
// In-memory (Previews / Tests)
try manager.configure(
schema: Schema([MyModel.self]),
configuration: ModelConfiguration(isStoredInMemoryOnly: true)
)// Single insert
func insert<T: PersistentModel>(_ model: T) throws
func insertAsync<T: PersistentModel>(_ model: T) async throws
// Batch insert — one save round-trip
func insertBatch<T: PersistentModel>(_ models: [T]) throws// All records (optional filter + sort)
func fetch<T: PersistentModel>(
_ type: T.Type,
predicate: Predicate<T>? = nil,
sortBy: [SortDescriptor<T>] = []
) throws -> [T]
func fetchAsync<T: PersistentModel>(...) async throws -> [T]
// Paged fetch
func fetchPaged<T: PersistentModel>(
_ type: T.Type,
predicate: Predicate<T>? = nil,
sortBy: [SortDescriptor<T>] = [],
offset: Int = 0,
limit: Int = 20
) throws -> [T]
// Count (no objects loaded)
func count<T: PersistentModel>(_ type: T.Type, predicate: Predicate<T>? = nil) throws -> Int
// First match
func fetchFirst<T: PersistentModel>(
_ type: T.Type,
predicate: Predicate<T>? = nil,
sortBy: [SortDescriptor<T>] = []
) throws -> T?func update<T: PersistentModel>(_ model: T, changes: (T) -> Void) throws
func updateAsync<T: PersistentModel>(_ model: T, changes: (T) -> Void) async throwsfunc delete<T: PersistentModel>(_ model: T) throws
func deleteAsync<T: PersistentModel>(_ model: T) async throws
// Bulk delete — returns count deleted
@discardableResult
func deleteAll<T: PersistentModel>(_ type: T.Type, predicate: Predicate<T>? = nil) throws -> Int// Manual save (CRUD methods call this automatically)
func save() throws
// Discard unsaved changes
func rollback() throws
// Check for pending changes
var hasUnsavedChanges: Bool { get }SwiftDataManager itself is @MainActor, so its own methods always run on the main thread — that's correct for @Query-driven UI, but wrong for heavy imports. For genuine off-main-thread work, performBackgroundTask hands your closure to SwiftDataBackgroundActor, a separate actor (built on Apple's @ModelActor macro) with its own isolated ModelContext:
// Genuinely runs off the main thread, auto-saves, then notifies changePublisher
func performBackgroundTask(_ work: @escaping @Sendable (ModelContext) throws -> Void) async throws
// Example: large import
try await manager.performBackgroundTask { context in
for item in hugeDataSet {
context.insert(MyModel(from: item))
}
// save happens automatically after the closure returns
}Rules for the closure:
- It must be
@Sendable— don't capturePersistentModelinstances fetched from another context (they aren'tSendable). Pass primitive values or DTOs in instead. - Insert/fetch/delete using the
ModelContextargument you're given, notmanager.mainContext. - If you need the resulting objects back on the main actor, re-fetch them there by identifier (
PersistentIdentifier) after the task completes, or rely onchangePublisherto trigger a fresh@Query/fetch.
Groups multiple operations into a single atomic save. On any error the context is rolled back automatically.
func transaction(_ work: (ModelContext) throws -> Void) throws
// Example
try manager.transaction { ctx in
ctx.insert(Note(title: "A", body: ""))
ctx.insert(Note(title: "B", body: ""))
// Both committed, or neither on throw
}Subscribe to changePublisher to receive a notification after every successful write:
manager.changePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.refreshUI() }
.store(in: &cancellables)// Main-actor context (for @Query / SwiftUI bindings)
let ctx: ModelContext? = manager.mainContext
// A second, independent context sharing the same container.
// Note: still handed back on the main actor — useful as a scratch context,
// but NOT true off-main-thread isolation. Use performBackgroundTask for that.
let bgCtx: ModelContext = try manager.newBackgroundContext()Use an in-memory store to avoid polluting the device database:
// SwiftUI Preview
#Preview {
let schema = Schema([Note.self])
try! SwiftDataManager.shared.configure(
schema: schema,
configuration: ModelConfiguration(isStoredInMemoryOnly: true)
)
try! SwiftDataManager.shared.insert(Note(title: "Preview Note", body: "Body"))
return NoteListView()
}
// XCTest
override func setUp() {
super.setUp()
let schema = Schema([Note.self])
try! SwiftDataManager.shared.configure(
schema: schema,
configuration: ModelConfiguration(isStoredInMemoryOnly: true)
)
}// Print record count to console
manager.debugPrintCount(for: Note.self)
// → SwiftDataManager [DEBUG] — Note count: 42
// Wipe all records of a type
try manager.nukeAll(Note.self)
// → SwiftDataManager [DEBUG] — Nuked 42 Note records.All methods throw SwiftDataManagerError:
| Case | When |
|---|---|
.notConfigured |
configure(schema:) was not called before use |
.containerCreationFailed(Error) |
ModelContainer init failed (bad schema, permissions, etc.) |
.saveFailed(Error) |
Context save failed (insert, update, delete, save, transaction) |
.fetchFailed(Error) |
A fetch-family call failed (fetch, fetchPaged, fetchFirst, count) |
do {
try manager.insert(note)
} catch SwiftDataManagerError.notConfigured {
// remind caller to run configure(schema:) at launch
} catch {
// underlying SwiftData / Core Data error
print(error.localizedDescription)
}| Aspect | Decision |
|---|---|
| Threading | @MainActor — all SwiftDataManager methods run on the main thread by design |
| Background | performBackgroundTask delegates to SwiftDataBackgroundActor, a separate @ModelActor-backed actor with its own isolated context — genuinely off the main thread, not just a context created from @MainActor |
| No singletons per model | One manager owns one container; all models share it |
| Combine | Thin PassthroughSubject — zero overhead when no subscribers |
| Logging | os.Logger with subsystem = Bundle.main.bundleIdentifier — visible in Console.app |
| Debug helpers | Gated behind #if DEBUG — zero production impact |
| Fetch error wrapping | All fetch-family methods (fetch, fetchPaged, fetchFirst, count) catch and rewrap underlying errors as .fetchFailed, matching how writes are wrapped as .saveFailed |
MIT — free to use, modify, and distribute.